diff --git a/.coveragerc b/.coveragerc index 44e424260c1..4be573201d6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -28,7 +28,6 @@ omit = homeassistant/components/adguard/sensor.py homeassistant/components/adguard/switch.py homeassistant/components/ads/* - homeassistant/components/aemet/weather_update_coordinator.py homeassistant/components/aftership/__init__.py homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py @@ -47,6 +46,9 @@ omit = homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/coordinator.py + homeassistant/components/airtouch5/__init__.py + homeassistant/components/airtouch5/climate.py + homeassistant/components/airtouch5/entity.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py @@ -110,6 +112,12 @@ omit = homeassistant/components/baf/sensor.py homeassistant/components/baf/switch.py homeassistant/components/baidu/tts.py + homeassistant/components/bang_olufsen/__init__.py + homeassistant/components/bang_olufsen/const.py + homeassistant/components/bang_olufsen/entity.py + homeassistant/components/bang_olufsen/media_player.py + homeassistant/components/bang_olufsen/util.py + homeassistant/components/bang_olufsen/websocket.py homeassistant/components/bbox/device_tracker.py homeassistant/components/bbox/sensor.py homeassistant/components/beewi_smartclim/sensor.py @@ -142,6 +150,8 @@ omit = homeassistant/components/braviatv/coordinator.py homeassistant/components/braviatv/media_player.py homeassistant/components/braviatv/remote.py + homeassistant/components/bring/coordinator.py + homeassistant/components/bring/todo.py homeassistant/components/broadlink/climate.py homeassistant/components/broadlink/light.py homeassistant/components/broadlink/remote.py @@ -174,6 +184,7 @@ omit = homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py homeassistant/components/comelit/alarm_control_panel.py + homeassistant/components/comelit/climate.py homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py @@ -273,7 +284,12 @@ omit = homeassistant/components/econet/climate.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py - homeassistant/components/ecovacs/* + homeassistant/components/ecovacs/controller.py + homeassistant/components/ecovacs/entity.py + homeassistant/components/ecovacs/image.py + homeassistant/components/ecovacs/number.py + homeassistant/components/ecovacs/util.py + homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py homeassistant/components/ecowitt/binary_sensor.py homeassistant/components/ecowitt/entity.py @@ -304,6 +320,8 @@ omit = homeassistant/components/elmax/cover.py homeassistant/components/elmax/switch.py homeassistant/components/elv/* + homeassistant/components/elvia/__init__.py + homeassistant/components/elvia/importer.py homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* @@ -332,6 +350,9 @@ omit = homeassistant/components/environment_canada/weather.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py + homeassistant/components/epion/__init__.py + homeassistant/components/epion/coordinator.py + homeassistant/components/epion/sensor.py homeassistant/components/epson/__init__.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py @@ -402,8 +423,6 @@ 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 @@ -421,6 +440,7 @@ omit = homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py homeassistant/components/foscam/coordinator.py + homeassistant/components/foscam/entity.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py @@ -461,9 +481,11 @@ omit = homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_pubsub/__init__.py + homeassistant/components/gpsd/__init__.py homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py + homeassistant/components/growatt_server/const.py homeassistant/components/growatt_server/sensor.py homeassistant/components/growatt_server/sensor_types/* homeassistant/components/gstreamer/media_player.py @@ -471,9 +493,11 @@ omit = homeassistant/components/guardian/__init__.py homeassistant/components/guardian/binary_sensor.py homeassistant/components/guardian/button.py + homeassistant/components/guardian/coordinator.py homeassistant/components/guardian/sensor.py homeassistant/components/guardian/switch.py homeassistant/components/guardian/util.py + homeassistant/components/guardian/valve.py homeassistant/components/habitica/__init__.py homeassistant/components/habitica/sensor.py homeassistant/components/harman_kardon_avr/media_player.py @@ -495,6 +519,9 @@ omit = homeassistant/components/hive/sensor.py homeassistant/components/hive/switch.py homeassistant/components/hive/water_heater.py + homeassistant/components/hko/__init__.py + homeassistant/components/hko/weather.py + homeassistant/components/hko/coordinator.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/__init__.py @@ -504,8 +531,6 @@ omit = homeassistant/components/home_connect/light.py homeassistant/components/home_connect/sensor.py homeassistant/components/home_connect/switch.py - homeassistant/components/home_plus_control/api.py - homeassistant/components/home_plus_control/switch.py homeassistant/components/homematic/__init__.py homeassistant/components/homematic/binary_sensor.py homeassistant/components/homematic/climate.py @@ -535,6 +560,8 @@ omit = homeassistant/components/hunterdouglas_powerview/shade_data.py homeassistant/components/hunterdouglas_powerview/util.py homeassistant/components/hvv_departures/__init__.py + homeassistant/components/huum/__init__.py + homeassistant/components/huum/climate.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/ialarm/alarm_control_panel.py @@ -661,10 +688,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/life360/__init__.py - homeassistant/components/life360/button.py - homeassistant/components/life360/coordinator.py - homeassistant/components/life360/device_tracker.py homeassistant/components/lightwave/* homeassistant/components/limitlessled/light.py homeassistant/components/linksys_smart/device_tracker.py @@ -692,10 +715,16 @@ omit = homeassistant/components/loqed/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/sensor.py - homeassistant/components/lupusec/* + homeassistant/components/lupusec/__init__.py + homeassistant/components/lupusec/alarm_control_panel.py + homeassistant/components/lupusec/binary_sensor.py + homeassistant/components/lupusec/entity.py + homeassistant/components/lupusec/switch.py homeassistant/components/lutron/__init__.py homeassistant/components/lutron/binary_sensor.py homeassistant/components/lutron/cover.py + homeassistant/components/lutron/entity.py + homeassistant/components/lutron/fan.py homeassistant/components/lutron/light.py homeassistant/components/lutron/switch.py homeassistant/components/lutron_caseta/__init__.py @@ -758,8 +787,11 @@ omit = homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/binary_sensor.py homeassistant/components/motionmount/entity.py homeassistant/components/motionmount/number.py + homeassistant/components/motionmount/select.py + homeassistant/components/motionmount/sensor.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py @@ -817,6 +849,7 @@ omit = homeassistant/components/nextcloud/coordinator.py homeassistant/components/nextcloud/entity.py homeassistant/components/nextcloud/sensor.py + homeassistant/components/nextcloud/update.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py @@ -999,6 +1032,11 @@ omit = homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py homeassistant/components/qvr_pro/* + homeassistant/components/rabbitair/__init__.py + homeassistant/components/rabbitair/const.py + homeassistant/components/rabbitair/coordinator.py + homeassistant/components/rabbitair/entity.py + homeassistant/components/rabbitair/fan.py homeassistant/components/rachio/__init__.py homeassistant/components/rachio/binary_sensor.py homeassistant/components/rachio/device.py @@ -1031,6 +1069,7 @@ omit = homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/number.py + homeassistant/components/renson/time.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py @@ -1069,6 +1108,9 @@ omit = homeassistant/components/ripple/sensor.py homeassistant/components/roborock/coordinator.py homeassistant/components/rocketchat/notify.py + homeassistant/components/romy/__init__.py + homeassistant/components/romy/coordinator.py + homeassistant/components/romy/vacuum.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py homeassistant/components/roomba/braava.py @@ -1309,15 +1351,13 @@ 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 @@ -1386,6 +1426,11 @@ omit = homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py + homeassistant/components/traccar_server/__init__.py + homeassistant/components/traccar_server/coordinator.py + homeassistant/components/traccar_server/device_tracker.py + homeassistant/components/traccar_server/entity.py + homeassistant/components/traccar_server/helpers.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py homeassistant/components/tractive/device_tracker.py @@ -1485,7 +1530,6 @@ omit = homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/__init__.py - homeassistant/components/vicare/binary_sensor.py homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py homeassistant/components/vicare/entity.py @@ -1605,7 +1649,9 @@ omit = homeassistant/components/yolink/entity.py homeassistant/components/yolink/light.py homeassistant/components/yolink/lock.py + homeassistant/components/yolink/number.py homeassistant/components/yolink/sensor.py + homeassistant/components/yolink/services.py homeassistant/components/yolink/siren.py homeassistant/components/yolink/switch.py homeassistant/components/youless/__init__.py @@ -1644,6 +1690,13 @@ omit = homeassistant/components/zwave_me/switch.py homeassistant/components/electrasmart/climate.py homeassistant/components/electrasmart/__init__.py + homeassistant/components/myuplink/__init__.py + homeassistant/components/myuplink/api.py + homeassistant/components/myuplink/application_credentials.py + homeassistant/components/myuplink/coordinator.py + homeassistant/components/myuplink/entity.py + homeassistant/components/myuplink/sensor.py + [report] # Regexes for lines to exclude from consideration diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44a81718e10..553f3bbdf0e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "1" }, - "appPort": ["8123:8123"], + // Port 5683 udp is used by Shelly integration + "appPort": ["8123:8123", "5683:5683/udp"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], "customizations": { "vscode": { diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png new file mode 100644 index 00000000000..8d71bf538d6 Binary files /dev/null and b/.github/assets/screenshot-integrations.png differ diff --git a/.github/assets/screenshot-states.png b/.github/assets/screenshot-states.png new file mode 100644 index 00000000000..15b527661a4 Binary files /dev/null and b/.github/assets/screenshot-states.png differ diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 378208fbdf4..16a48d3cb48 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,8 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.11" + DEFAULT_PYTHON: "3.12" + PIP_TIMEOUT: 60 jobs: init: @@ -102,7 +103,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 +114,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 @@ -179,6 +180,15 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt + - name: Adjustments for 64-bit + if: matrix.arch == 'amd64' || matrix.arch == 'aarch64' + run: | + # Some speedups are only available on 64-bit, and since + # we build 32bit images on 64bit hosts, we only enable + # the speed ups on 64bit since the wheels for 32bit + # are not available. + sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt + - name: Download Translations run: python3 -m script.translations download env: @@ -197,7 +207,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.12.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ @@ -274,7 +284,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.12.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2255b3f145c..f7854ef88df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,8 +35,8 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2024.1" + MYPY_CACHE_VERSION: 7 + HA_SHORT_VERSION: "2024.2" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version @@ -103,7 +103,7 @@ jobs: echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.0 id: core with: filters: .core_files.yaml @@ -118,7 +118,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.0 id: integrations with: filters: .integration_paths.yaml @@ -231,7 +231,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.0 with: path: venv key: >- @@ -246,7 +246,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -276,7 +276,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -285,7 +285,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -316,7 +316,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -325,7 +325,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -355,7 +355,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -364,7 +364,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -454,7 +454,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.0 with: path: venv lookup-only: true @@ -463,7 +463,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.0 with: path: ${{ env.PIP_CACHE }} key: >- @@ -517,7 +517,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -549,7 +549,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -582,7 +582,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -597,14 +597,14 @@ jobs: run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant + pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant - name: Run pylint (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} + pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} mypy: name: Check mypy @@ -633,7 +633,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -641,7 +641,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.0 with: path: .mypy_cache key: >- @@ -708,7 +708,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -860,7 +860,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -898,6 +898,7 @@ jobs: python --version set -o pipefail mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g") + echo "mariadb=${mariadb}" >> $GITHUB_OUTPUT cov_params=() if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant.components.recorder") @@ -929,7 +930,8 @@ jobs: if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: - name: coverage-${{ matrix.python-version }}-mariadb + name: coverage-${{ matrix.python-version }}-mariadb-${{ + steps.pytest-partial.outputs.mariadb }} path: coverage.xml - name: Check dirty run: | @@ -984,7 +986,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -1022,6 +1024,7 @@ jobs: python --version set -o pipefail postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g") + echo "postgresql=${postgresql}" >> $GITHUB_OUTPUT cov_params=() if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant.components.recorder") @@ -1054,7 +1057,8 @@ jobs: if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.0 with: - name: coverage-${{ matrix.python-version }}-postgresql + name: coverage-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.postgresql }} path: coverage.xml - name: Check dirty run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1dc36b9fa34..bdec74a3aff 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@v3.22.12 + uses: github/codeql-action/init@v3.23.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.22.12 + uses: github/codeql-action/analyze@v3.23.2 with: category: "/language:python" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b23f1b5b05..c9b1a76cc37 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,14 +99,14 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev" + apk: "libffi-dev;openssl-dev;yaml-dev;nasm" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -160,6 +160,12 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} fi + + # Some speedups are only for 64-bit + if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then + sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file} + fi + done - name: Split requirements all @@ -192,7 +198,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -207,42 +213,42 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79bf7e87903..0db0244edc9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata diff --git a/.strict-typing b/.strict-typing index 1bc3308f533..bd92da2fc50 100644 --- a/.strict-typing +++ b/.strict-typing @@ -42,6 +42,7 @@ homeassistant.components homeassistant.components.abode.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* +homeassistant.components.acmeda.* homeassistant.components.actiontec.* homeassistant.components.adax.* homeassistant.components.adguard.* @@ -49,7 +50,10 @@ 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.airtouch5.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* @@ -58,52 +62,81 @@ 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.analytics_insights.* 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.api.* 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.bang_olufsen.* 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_adapters.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* homeassistant.components.braviatv.* homeassistant.components.brother.* homeassistant.components.browser.* +homeassistant.components.bthome.* homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* +homeassistant.components.cert_expiry.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* +homeassistant.components.co2signal.* +homeassistant.components.command_line.* +homeassistant.components.config.* 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.default_config.* homeassistant.components.demo.* homeassistant.components.derivative.* homeassistant.components.device_automation.* @@ -114,11 +147,18 @@ homeassistant.components.dhcp.* homeassistant.components.diagnostics.* homeassistant.components.discovergy.* homeassistant.components.dlna_dmr.* +homeassistant.components.dlna_dms.* homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* +homeassistant.components.downloader.* homeassistant.components.dsmr.* +homeassistant.components.duckdns.* homeassistant.components.dunehd.* +homeassistant.components.duotecno.* +homeassistant.components.easyenergy.* +homeassistant.components.ecovacs.* +homeassistant.components.ecowitt.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* @@ -126,7 +166,9 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* +homeassistant.components.energyzero.* homeassistant.components.enigma2.* +homeassistant.components.enphase_envoy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* @@ -148,12 +190,15 @@ homeassistant.components.fritzbox_callmonitor.* homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fully_kiosk.* +homeassistant.components.generic_hygrostat.* +homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* +homeassistant.components.google_assistant_sdk.* homeassistant.components.google_sheets.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* @@ -163,9 +208,9 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.history_stats.* homeassistant.components.holiday.* -homeassistant.components.homeassistant.exposed_entities -homeassistant.components.homeassistant.triggers.event +homeassistant.components.homeassistant.* homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* @@ -184,6 +229,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.* @@ -196,6 +242,9 @@ homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* homeassistant.components.integration.* +homeassistant.components.intent.* +homeassistant.components.intent_script.* +homeassistant.components.ios.* homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.islamic_prayer_times.* @@ -208,11 +257,13 @@ homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* +homeassistant.components.lamarzocco.* homeassistant.components.lametric.* homeassistant.components.laundrify.* homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* +homeassistant.components.led_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* @@ -228,15 +279,18 @@ homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* +homeassistant.components.map.* homeassistant.components.mastodon.* homeassistant.components.matrix.* homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* homeassistant.components.media_source.* +homeassistant.components.met_eireann.* homeassistant.components.metoffice.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* +homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* @@ -244,7 +298,9 @@ homeassistant.components.moon.* homeassistant.components.mopeka.* homeassistant.components.motionmount.* homeassistant.components.mqtt.* +homeassistant.components.my.* homeassistant.components.mysensors.* +homeassistant.components.myuplink.* homeassistant.components.nam.* homeassistant.components.nanoleaf.* homeassistant.components.neato.* @@ -253,20 +309,24 @@ homeassistant.components.netatmo.* homeassistant.components.network.* homeassistant.components.nextdns.* homeassistant.components.nfandroidtv.* +homeassistant.components.nightscout.* homeassistant.components.nissan_leaf.* homeassistant.components.no_ip.* homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* homeassistant.components.openuv.* +homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* +homeassistant.components.p1_monitor.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* @@ -275,6 +335,7 @@ homeassistant.components.plugwise.* homeassistant.components.poolsense.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* +homeassistant.components.prometheus.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* @@ -282,7 +343,9 @@ homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* +homeassistant.components.rabbitair.* homeassistant.components.radarr.* +homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* @@ -292,11 +355,13 @@ homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.repairs.* homeassistant.components.rest.* +homeassistant.components.rest_command.* homeassistant.components.rfxtrx.* homeassistant.components.rhasspy.* homeassistant.components.ridwell.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.roku.* +homeassistant.components.romy.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.rtsp_to_webrtc.* @@ -306,6 +371,7 @@ homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.schedule.* homeassistant.components.scrape.* +homeassistant.components.search.* homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* @@ -313,8 +379,10 @@ homeassistant.components.sensor.* homeassistant.components.senz.* homeassistant.components.sfr_box.* homeassistant.components.shelly.* +homeassistant.components.shopping_list.* homeassistant.components.simplepush.* homeassistant.components.simplisafe.* +homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* homeassistant.components.sleepiq.* @@ -330,6 +398,7 @@ 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.* @@ -338,6 +407,8 @@ homeassistant.components.switchbee.* homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* +homeassistant.components.system_health.* +homeassistant.components.system_log.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* @@ -345,14 +416,22 @@ homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.technove.* +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.timer.* +homeassistant.components.tod.* +homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* homeassistant.components.tplink_omada.* +homeassistant.components.trace.* homeassistant.components.tractive.* homeassistant.components.tradfri.* homeassistant.components.trafikverket_camera.* @@ -376,10 +455,13 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* +homeassistant.components.wake_word.* homeassistant.components.wallbox.* +homeassistant.components.waqi.* homeassistant.components.water_heater.* homeassistant.components.watttime.* homeassistant.components.weather.* +homeassistant.components.webhook.* homeassistant.components.webostv.* homeassistant.components.websocket_api.* homeassistant.components.wemo.* @@ -388,8 +470,10 @@ homeassistant.components.withings.* homeassistant.components.wiz.* homeassistant.components.wled.* homeassistant.components.worldclock.* +homeassistant.components.xiaomi_ble.* homeassistant.components.yale_smart_alarm.* homeassistant.components.yalexs_ble.* +homeassistant.components.youtube.* homeassistant.components.zeroconf.* homeassistant.components.zodiac.* homeassistant.components.zone.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b8cb8a4e61a..d6657f04557 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -157,6 +157,20 @@ "kind": "build", "isDefault": true } + }, + { + "label": "Install integration requirements", + "detail": "Install all requirements of a given integration.", + "type": "shell", + "command": "${command:python.interpreterPath} -m script.install_integration_requirements ${input:integrationName}", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + } } ], "inputs": [ diff --git a/CODEOWNERS b/CODEOWNERS index dd538ace0ec..144883db68f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,12 +45,14 @@ build.json @home-assistant/supervisor /tests/components/airnow/ @asymworks /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 -/homeassistant/components/airthings/ @danielhiversen -/tests/components/airthings/ @danielhiversen +/homeassistant/components/airthings/ @danielhiversen @LaStrada +/tests/components/airthings/ @danielhiversen @LaStrada /homeassistant/components/airthings_ble/ @vincegio @LaStrada /tests/components/airthings_ble/ @vincegio @LaStrada /homeassistant/components/airtouch4/ @samsinnamon /tests/components/airtouch4/ @samsinnamon +/homeassistant/components/airtouch5/ @danzel +/tests/components/airtouch5/ @danzel /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya @@ -76,6 +78,8 @@ build.json @home-assistant/supervisor /homeassistant/components/amcrest/ @flacjacket /homeassistant/components/analytics/ @home-assistant/core @ludeeus /tests/components/analytics/ @home-assistant/core @ludeeus +/homeassistant/components/analytics_insights/ @joostlek +/tests/components/analytics_insights/ @joostlek /homeassistant/components/android_ip_webcam/ @engrbm87 /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 @@ -145,6 +149,8 @@ build.json @home-assistant/supervisor /tests/components/baf/ @bdraco @jfroy /homeassistant/components/balboa/ @garbled1 @natekspencer /tests/components/balboa/ @garbled1 @natekspencer +/homeassistant/components/bang_olufsen/ @mj23000 +/tests/components/bang_olufsen/ @mj23000 /homeassistant/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG /homeassistant/components/beewi_smartclim/ @alemuro @@ -174,6 +180,8 @@ build.json @home-assistant/supervisor /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed +/homeassistant/components/bring/ @miaucl @tr4nt0r +/tests/components/bring/ @miaucl @tr4nt0r /homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /homeassistant/components/brother/ @bieniu @@ -317,13 +325,12 @@ build.json @home-assistant/supervisor /tests/components/eafm/ @Jc2k /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas -/homeassistant/components/ecobee/ @marcolivierarsenault -/tests/components/ecobee/ @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23 -/homeassistant/components/ecovacs/ @OverloadUT @mib1185 +/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus +/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob @@ -340,6 +347,8 @@ build.json @home-assistant/supervisor /homeassistant/components/elmax/ @albertogeniola /tests/components/elmax/ @albertogeniola /homeassistant/components/elv/ @majuss +/homeassistant/components/elvia/ @ludeeus +/tests/components/elvia/ @ludeeus /homeassistant/components/emby/ @mezz64 /homeassistant/components/emoncms/ @borpin /homeassistant/components/emonitor/ @bdraco @@ -361,6 +370,8 @@ build.json @home-assistant/supervisor /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 +/homeassistant/components/epion/ @lhgravendeel +/tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth @@ -494,7 +505,10 @@ build.json @home-assistant/supervisor /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax /tests/components/govee_ble/ @bdraco @PierreAronnax -/homeassistant/components/gpsd/ @fabaff +/homeassistant/components/govee_light_local/ @Galorhallen +/tests/components/govee_light_local/ @Galorhallen +/homeassistant/components/gpsd/ @fabaff @jrieger +/tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche /homeassistant/components/greeneye_monitor/ @jkeljo @@ -528,14 +542,14 @@ build.json @home-assistant/supervisor /tests/components/history/ @home-assistant/core /homeassistant/components/hive/ @Rendili @KJonline /tests/components/hive/ @Rendili @KJonline +/homeassistant/components/hko/ @MisterCommand +/tests/components/hko/ @MisterCommand /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard -/homeassistant/components/holiday/ @jrieger -/tests/components/holiday/ @jrieger +/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 -/tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core @@ -570,6 +584,8 @@ build.json @home-assistant/supervisor /tests/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/homeassistant/components/huum/ @frwickst +/tests/components/huum/ @frwickst /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @ptcryan @@ -653,8 +669,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen -/homeassistant/components/jvc_projector/ @SteveEasley -/tests/components/jvc_projector/ @SteveEasley +/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi +/tests/components/jvc_projector/ @SteveEasley @msavazzi /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -685,6 +701,8 @@ build.json @home-assistant/supervisor /tests/components/kulersky/ @emlove /homeassistant/components/lacrosse_view/ @IceBotYT /tests/components/lacrosse_view/ @IceBotYT +/homeassistant/components/lamarzocco/ @zweckj +/tests/components/lamarzocco/ @zweckj /homeassistant/components/lametric/ @robbiet480 @frenck @bachya /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis @@ -701,13 +719,13 @@ build.json @home-assistant/supervisor /tests/components/lcn/ @alengwenus /homeassistant/components/ld2410_ble/ @930913 /tests/components/ld2410_ble/ @930913 +/homeassistant/components/leaone/ @bdraco +/tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco /homeassistant/components/lg_netcast/ @Drafteed /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob -/homeassistant/components/life360/ @pnbruckner -/tests/components/life360/ @pnbruckner /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linear_garage_door/ @IceBotYT @@ -744,8 +762,10 @@ build.json @home-assistant/supervisor /homeassistant/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck -/homeassistant/components/lupusec/ @majuss -/homeassistant/components/lutron/ @cdheiser +/homeassistant/components/lupusec/ @majuss @suaveolent +/tests/components/lupusec/ @majuss @suaveolent +/homeassistant/components/lutron/ @cdheiser @wilburCForce +/tests/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron_caseta/ @swails @bdraco @danaues /tests/components/lutron_caseta/ @swails @bdraco @danaues /homeassistant/components/lyric/ @timmo001 @@ -766,8 +786,6 @@ build.json @home-assistant/supervisor /homeassistant/components/media_source/ @hunterjm /tests/components/media_source/ @hunterjm /homeassistant/components/mediaroom/ @dgomes -/homeassistant/components/melcloud/ @vilppuvuorinen -/tests/components/melcloud/ @vilppuvuorinen /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator @@ -830,6 +848,8 @@ build.json @home-assistant/supervisor /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff /tests/components/mystrom/ @fabaff +/homeassistant/components/myuplink/ @pajzo +/tests/components/myuplink/ @pajzo /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu @@ -1034,8 +1054,10 @@ 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/rabbitair/ @rabbit-air +/tests/components/rabbitair/ @rabbit-air +/homeassistant/components/rachio/ @bdraco @rfverbruggen +/tests/components/rachio/ @bdraco @rfverbruggen /homeassistant/components/radarr/ @tkdrob /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck @@ -1047,6 +1069,8 @@ build.json @home-assistant/supervisor /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin /tests/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin +/homeassistant/components/rainforest_raven/ @cottsay +/tests/components/rainforest_raven/ @cottsay /homeassistant/components/rainmachine/ @bachya /tests/components/rainmachine/ @bachya /homeassistant/components/random/ @fabaff @@ -1099,6 +1123,8 @@ build.json @home-assistant/supervisor /tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington +/homeassistant/components/romy/ @xeniter +/tests/components/romy/ @xeniter /homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /homeassistant/components/roon/ @pavoni @@ -1299,8 +1325,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna -/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna +/homeassistant/components/tado/ @chiefdragon @erwindouna +/tests/components/tado/ @chiefdragon @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck @@ -1309,19 +1335,25 @@ build.json @home-assistant/supervisor /tests/components/tailwind/ @frenck /homeassistant/components/tami4/ @Guy293 /tests/components/tami4/ @Guy293 -/homeassistant/components/tankerkoenig/ @guillempages @mib1185 -/tests/components/tankerkoenig/ @guillempages @mib1185 +/homeassistant/components/tankerkoenig/ @guillempages @mib1185 @jpbede +/tests/components/tankerkoenig/ @guillempages @mib1185 @jpbede /homeassistant/components/tapsaff/ @bazwilliams /homeassistant/components/tasmota/ @emontnemery /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/technove/ @Moustachauve +/tests/components/technove/ @Moustachauve +/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/teslemetry/ @Bre77 +/tests/components/teslemetry/ @Bre77 /homeassistant/components/tessie/ @Bre77 /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core @@ -1329,8 +1361,8 @@ build.json @home-assistant/supervisor /homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco -/homeassistant/components/thermopro/ @bdraco -/tests/components/thermopro/ @bdraco +/homeassistant/components/thermopro/ @bdraco @h3ss +/tests/components/thermopro/ @bdraco @h3ss /homeassistant/components/thethingsnetwork/ @fabaff /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core @@ -1355,12 +1387,14 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco -/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 +/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 /homeassistant/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus +/homeassistant/components/traccar_server/ @ludeeus +/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu diff --git a/Dockerfile b/Dockerfile index 43b21ab3ba8..da46f71ad22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,12 +28,19 @@ RUN \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ - && \ + && if [ "${BUILD_ARCH}" = "i386" ]; then \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + linux32 pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt; \ + else \ 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 + -r homeassistant/requirements_all.txt; \ + fi ## Setup Home Assistant Core COPY . homeassistant/ diff --git a/Dockerfile.dev b/Dockerfile.dev index a1143adde89..453b922cd0b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -16,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/README.rst b/README.rst index 0dc98a379a3..be3e18af380 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ of a component, check the `Home Assistant help section None: + """Set up the auth manager.""" + hass = self.hass + hass.async_add_shutdown_job( + HassJob( + self._async_cancel_expiration_schedule, job_type=HassJobType.Callback + ) + ) + self._async_track_next_refresh_token_expiration() @property def auth_providers(self) -> list[AuthProvider]: @@ -423,6 +449,11 @@ class AuthManager: else: token_type = models.TOKEN_TYPE_NORMAL + if token_type is models.TOKEN_TYPE_NORMAL: + expire_at = time.time() + REFRESH_TOKEN_EXPIRATION + else: + expire_at = None + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): raise ValueError( "System generated users can only have system type refresh tokens" @@ -454,48 +485,81 @@ class AuthManager: client_icon, token_type, access_token_expiration, + expire_at, credential, ) - async def async_get_refresh_token( - self, token_id: str - ) -> models.RefreshToken | None: + @callback + def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: """Get refresh token by id.""" - return await self._store.async_get_refresh_token(token_id) + return self._store.async_get_refresh_token(token_id) - async def async_get_refresh_token_by_token( + @callback + def async_get_refresh_token_by_token( self, token: str ) -> models.RefreshToken | None: """Get refresh token by token.""" - return await self._store.async_get_refresh_token_by_token(token) + return self._store.async_get_refresh_token_by_token(token) - async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken - ) -> None: + @callback + def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: """Delete a refresh token.""" - await self._store.async_remove_refresh_token(refresh_token) + self._store.async_remove_refresh_token(refresh_token) - callbacks = self._revoke_callbacks.pop(refresh_token.id, []) + callbacks = self._revoke_callbacks.pop(refresh_token.id, ()) for revoke_callback in callbacks: revoke_callback() + @callback + def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None: + """Remove expired refresh tokens.""" + now = time.time() + for token in self._store.async_get_refresh_tokens(): + if (expire_at := token.expire_at) is not None and expire_at <= now: + self.async_remove_refresh_token(token) + self._async_track_next_refresh_token_expiration() + + @callback + def _async_track_next_refresh_token_expiration(self) -> None: + """Initialise all token expiration scheduled tasks.""" + next_expiration = time.time() + REFRESH_TOKEN_EXPIRATION + for token in self._store.async_get_refresh_tokens(): + if ( + expire_at := token.expire_at + ) is not None and expire_at < next_expiration: + next_expiration = expire_at + + self._expire_callback = async_track_point_in_utc_time( + self.hass, + self._remove_expired_job, + dt_util.utc_from_timestamp(next_expiration), + ) + + @callback + def _async_cancel_expiration_schedule(self) -> None: + """Cancel tracking of expired refresh tokens.""" + if self._expire_callback: + self._expire_callback() + self._expire_callback = None + + @callback + def _async_unregister( + self, callbacks: set[CALLBACK_TYPE], callback_: CALLBACK_TYPE + ) -> None: + """Unregister a callback.""" + callbacks.remove(callback_) + @callback def async_register_revoke_token_callback( self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register a callback to be called when the refresh token id is revoked.""" if refresh_token_id not in self._revoke_callbacks: - self._revoke_callbacks[refresh_token_id] = [] + self._revoke_callbacks[refresh_token_id] = set() callbacks = self._revoke_callbacks[refresh_token_id] - callbacks.append(revoke_callback) - - @callback - def unregister() -> None: - if revoke_callback in callbacks: - callbacks.remove(revoke_callback) - - return unregister + callbacks.add(revoke_callback) + return partial(self._async_unregister, callbacks, revoke_callback) @callback def async_create_access_token( @@ -552,16 +616,15 @@ class AuthManager: if provider := self._async_resolve_provider(refresh_token): provider.async_validate_refresh_token(refresh_token, remote_ip) - async def async_validate_access_token( - self, token: str - ) -> models.RefreshToken | None: + @callback + def async_validate_access_token(self, token: str) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None - refresh_token = await self.async_get_refresh_token( + refresh_token = self.async_get_refresh_token( cast(str, unverif_claims.get("iss")) ) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 50d5d630429..983ba7da6a1 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,10 +1,9 @@ """Storage for auth models.""" from __future__ import annotations -import asyncio -from collections import OrderedDict from datetime import timedelta import hmac +import itertools from logging import getLogger from typing import Any @@ -19,6 +18,7 @@ from .const import ( GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER, + REFRESH_TOKEN_EXPIRATION, ) from .permissions import system_policies from .permissions.models import PermissionLookup @@ -43,44 +43,28 @@ class AuthStore: def __init__(self, hass: HomeAssistant) -> None: """Initialize the auth store.""" self.hass = hass - self._users: dict[str, models.User] | None = None - self._groups: dict[str, models.Group] | None = None - self._perm_lookup: PermissionLookup | None = None + self._loaded = False + self._users: dict[str, models.User] = None # type: ignore[assignment] + self._groups: dict[str, models.Group] = None # type: ignore[assignment] + self._perm_lookup: PermissionLookup = None # type: ignore[assignment] self._store = Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) - self._lock = asyncio.Lock() async def async_get_groups(self) -> list[models.Group]: """Retrieve all users.""" - if self._groups is None: - await self._async_load() - assert self._groups is not None - return list(self._groups.values()) async def async_get_group(self, group_id: str) -> models.Group | None: """Retrieve all users.""" - if self._groups is None: - await self._async_load() - assert self._groups is not None - return self._groups.get(group_id) async def async_get_users(self) -> list[models.User]: """Retrieve all users.""" - if self._users is None: - await self._async_load() - assert self._users is not None - return list(self._users.values()) async def async_get_user(self, user_id: str) -> models.User | None: """Retrieve a user by id.""" - if self._users is None: - await self._async_load() - assert self._users is not None - return self._users.get(user_id) async def async_create_user( @@ -94,12 +78,6 @@ class AuthStore: local_only: bool | None = None, ) -> models.User: """Create a new user.""" - if self._users is None: - await self._async_load() - - assert self._users is not None - assert self._groups is not None - groups = [] for group_id in group_ids or []: if (group := self._groups.get(group_id)) is None: @@ -145,10 +123,6 @@ class AuthStore: async def async_remove_user(self, user: models.User) -> None: """Remove a user.""" - if self._users is None: - await self._async_load() - assert self._users is not None - self._users.pop(user.id) self._async_schedule_save() @@ -161,8 +135,6 @@ class AuthStore: local_only: bool | None = None, ) -> None: """Update a user.""" - assert self._groups is not None - if group_ids is not None: groups = [] for grid in group_ids: @@ -171,7 +143,6 @@ class AuthStore: groups.append(group) user.groups = groups - user.invalidate_permission_cache() for attr_name, value in ( ("name", name), @@ -195,10 +166,6 @@ class AuthStore: async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): found = None @@ -221,6 +188,7 @@ class AuthStore: client_icon: str | None = None, token_type: str = models.TOKEN_TYPE_NORMAL, access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + expire_at: float | None = None, credential: models.Credentials | None = None, ) -> models.RefreshToken: """Create a new token for a user.""" @@ -229,6 +197,7 @@ class AuthStore: "client_id": client_id, "token_type": token_type, "access_token_expiration": access_token_expiration, + "expire_at": expire_at, "credential": credential, } if client_name: @@ -242,27 +211,17 @@ class AuthStore: self._async_schedule_save() return refresh_token - async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken - ) -> None: + @callback + def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: """Remove a refresh token.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): if user.refresh_tokens.pop(refresh_token.id, None): self._async_schedule_save() break - async def async_get_refresh_token( - self, token_id: str - ) -> models.RefreshToken | None: + @callback + def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: """Get refresh token by id.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): refresh_token = user.refresh_tokens.get(token_id) if refresh_token is not None: @@ -270,14 +229,11 @@ class AuthStore: return None - async def async_get_refresh_token_by_token( + @callback + def async_get_refresh_token_by_token( self, token: str ) -> models.RefreshToken | None: """Get refresh token by token.""" - if self._users is None: - await self._async_load() - assert self._users is not None - found = None for user in self._users.values(): @@ -287,6 +243,15 @@ class AuthStore: return found + @callback + def async_get_refresh_tokens(self) -> list[models.RefreshToken]: + """Get all refresh tokens.""" + return list( + itertools.chain.from_iterable( + user.refresh_tokens.values() for user in self._users.values() + ) + ) + @callback def async_log_refresh_token_usage( self, refresh_token: models.RefreshToken, remote_ip: str | None = None @@ -294,35 +259,34 @@ class AuthStore: """Update refresh token last used information.""" refresh_token.last_used_at = dt_util.utcnow() refresh_token.last_used_ip = remote_ip + if refresh_token.expire_at: + refresh_token.expire_at = ( + refresh_token.last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION + ) self._async_schedule_save() - async def _async_load(self) -> None: + async def async_load(self) -> None: # noqa: C901 """Load the users.""" - async with self._lock: - if self._users is not None: - return - await self._async_load_task() + if self._loaded: + raise RuntimeError("Auth storage is already loaded") + self._loaded = True - async def _async_load_task(self) -> None: - """Load the users.""" dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) data = await self._store.async_load() - # Make sure that we're not overriding data if 2 loads happened at the - # same time - if self._users is not None: - return + perm_lookup = PermissionLookup(ent_reg, dev_reg) + self._perm_lookup = perm_lookup - self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg) + now_ts = dt_util.utcnow().timestamp() if data is None or not isinstance(data, dict): self._set_defaults() return - users: dict[str, models.User] = OrderedDict() - groups: dict[str, models.Group] = OrderedDict() - credentials: dict[str, models.Credentials] = OrderedDict() + users: dict[str, models.User] = {} + groups: dict[str, models.Group] = {} + credentials: dict[str, models.Credentials] = {} # Soft-migrating data as we load. We are going to make sure we have a # read only group and an admin group. There are two states that we can @@ -469,6 +433,14 @@ class AuthStore: else: last_used_at = None + if ( + expire_at := rt_dict.get("expire_at") + ) is None and token_type == models.TOKEN_TYPE_NORMAL: + if last_used_at: + expire_at = last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION + else: + expire_at = now_ts + REFRESH_TOKEN_EXPIRATION + token = models.RefreshToken( id=rt_dict["id"], user=users[rt_dict["user_id"]], @@ -485,6 +457,7 @@ class AuthStore: jwt_key=rt_dict["jwt_key"], last_used_at=last_used_at, last_used_ip=rt_dict.get("last_used_ip"), + expire_at=expire_at, version=rt_dict.get("version"), ) if "credential_id" in rt_dict: @@ -494,20 +467,16 @@ class AuthStore: self._groups = groups self._users = users + self._async_schedule_save() + @callback def _async_schedule_save(self) -> None: """Save users.""" - if self._users is None: - return - self._store.async_delay_save(self._data_to_save, 1) @callback def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return the data to store.""" - assert self._users is not None - assert self._groups is not None - users = [ { "id": user.id, @@ -564,6 +533,7 @@ class AuthStore: if refresh_token.last_used_at else None, "last_used_ip": refresh_token.last_used_ip, + "expire_at": refresh_token.expire_at, "credential_id": refresh_token.credential.id if refresh_token.credential else None, @@ -582,9 +552,9 @@ class AuthStore: def _set_defaults(self) -> None: """Set default values for auth store.""" - self._users = OrderedDict() + self._users = {} - groups: dict[str, models.Group] = OrderedDict() + groups: dict[str, models.Group] = {} admin_group = _system_admin_group() groups[admin_group.id] = admin_group user_group = _system_user_group() diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 5e17e752bdd..704f5d1d57c 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -3,6 +3,7 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) +REFRESH_TOKEN_EXPIRATION = timedelta(days=90).total_seconds() GROUP_ID_ADMIN = "system-admin" GROUP_ID_USER = "system-users" diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 57989849367..4c5b3a2380b 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -152,7 +152,7 @@ class NotifyAuthModule(MultiFactorAuthModule): """Return list of notify services.""" unordered_services = set() - for service in self.hass.services.async_services().get("notify", {}): + for service in self.hass.services.async_services_for_domain("notify"): if service not in self._exclude: unordered_services.add(service) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 32a700d65f9..4cf94401478 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -3,10 +3,12 @@ from __future__ import annotations from datetime import datetime, timedelta import secrets -from typing import NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple import uuid import attr +from attr import Attribute +from attr.setters import validate from homeassistant.const import __version__ from homeassistant.util import dt as dt_util @@ -14,6 +16,12 @@ from homeassistant.util import dt as dt_util from . import permissions as perm_mdl from .const import GROUP_ID_ADMIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + TOKEN_TYPE_NORMAL = "normal" TOKEN_TYPE_SYSTEM = "system" TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token" @@ -29,19 +37,27 @@ class Group: system_generated: bool = attr.ib(default=False) -@attr.s(slots=True) +def _handle_permissions_change(self: User, user_attr: Attribute, new: Any) -> Any: + """Handle a change to a permissions.""" + self.invalidate_cache() + return validate(self, user_attr, new) + + +@attr.s(slots=False) class User: """A user.""" name: str | None = attr.ib() perm_lookup: perm_mdl.PermissionLookup = attr.ib(eq=False, order=False) id: str = attr.ib(factory=lambda: uuid.uuid4().hex) - is_owner: bool = attr.ib(default=False) - is_active: bool = attr.ib(default=False) + is_owner: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) + is_active: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) system_generated: bool = attr.ib(default=False) local_only: bool = attr.ib(default=False) - groups: list[Group] = attr.ib(factory=list, eq=False, order=False) + groups: list[Group] = attr.ib( + factory=list, eq=False, order=False, on_setattr=_handle_permissions_change + ) # List of credentials of a user. credentials: list[Credentials] = attr.ib(factory=list, eq=False, order=False) @@ -51,40 +67,31 @@ class User: factory=dict, eq=False, order=False ) - _permissions: perm_mdl.PolicyPermissions | None = attr.ib( - init=False, - eq=False, - order=False, - default=None, - ) - - @property + @cached_property def permissions(self) -> perm_mdl.AbstractPermissions: """Return permissions object for user.""" if self.is_owner: return perm_mdl.OwnerPermissions - - if self._permissions is not None: - return self._permissions - - self._permissions = perm_mdl.PolicyPermissions( + return perm_mdl.PolicyPermissions( perm_mdl.merge_policies([group.policy for group in self.groups]), self.perm_lookup, ) - return self._permissions - - @property + @cached_property def is_admin(self) -> bool: """Return if user is part of the admin group.""" - if self.is_owner: - return True + return self.is_owner or ( + self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) + ) - return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) - - def invalidate_permission_cache(self) -> None: - """Invalidate permission cache.""" - self._permissions = None + def invalidate_cache(self) -> None: + """Invalidate permission and is_admin cache.""" + for attr_to_invalidate in ("permissions", "is_admin"): + # try is must more efficient than suppress + try: # noqa: SIM105 + delattr(self, attr_to_invalidate) + except AttributeError: + pass @attr.s(slots=True) @@ -110,6 +117,8 @@ class RefreshToken: last_used_at: datetime | None = attr.ib(default=None) last_used_ip: str | None = attr.ib(default=None) + expire_at: float | None = attr.ib(default=None) + credential: Credentials | None = attr.ib(default=None) version: str | None = attr.ib(default=__version__) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 83b2f18719f..cc3d87319d0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol import yarl -from . import config as conf_util, config_entries, core, loader +from . import config as conf_util, config_entries, core, loader, requirements from .components import http from .const import ( FORMAT_DATETIME, @@ -39,7 +39,6 @@ from .helpers import ( from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType from .setup import ( - DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, async_notify_setup_error, @@ -106,6 +105,52 @@ STAGE_1_INTEGRATIONS = { # Ensure supervisor is available "hassio", } +DEFAULT_INTEGRATIONS = { + # These integrations are set up unless recovery mode is activated. + # + # Integrations providing core functionality: + "application_credentials", + "frontend", + "hardware", + "logger", + "network", + "system_health", + # + # Key-feature: + "automation", + "person", + "scene", + "script", + "tag", + "zone", + # + # Built-in helpers: + "counter", + "input_boolean", + "input_button", + "input_datetime", + "input_number", + "input_select", + "input_text", + "schedule", + "timer", +} +DEFAULT_INTEGRATIONS_RECOVERY_MODE = { + # These integrations are set up if recovery mode is activated. + "frontend", +} +DEFAULT_INTEGRATIONS_SUPERVISOR = { + # These integrations are set up if using the Supervisor + "hassio", +} +DEFAULT_INTEGRATIONS_NON_SUPERVISOR = { + # These integrations are set up if not using the Supervisor + "backup", +} +CRITICAL_INTEGRATIONS = { + # Recovery mode is activated if these integrations fail to set up + "frontend", +} async def async_setup_hass( @@ -165,11 +210,11 @@ async def async_setup_hass( _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") recovery_mode = True - elif ( - "frontend" in hass.data.get(DATA_SETUP, {}) - and "frontend" not in hass.config.components - ): - _LOGGER.warning("Detected that frontend did not load. Activating recovery mode") + elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): + _LOGGER.warning( + "Detected that %s did not load. Activating recovery mode", + ",".join(CRITICAL_INTEGRATIONS), + ) # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken with contextlib.suppress(asyncio.TimeoutError): @@ -229,7 +274,7 @@ def open_hass_ui(hass: core.HomeAssistant) -> None: ) -async def load_registries(hass: core.HomeAssistant) -> None: +async def async_load_base_functionality(hass: core.HomeAssistant) -> None: """Load the registries and cache the result of platform.uname().processor.""" if DATA_REGISTRIES_LOADED in hass.data: return @@ -256,6 +301,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: hass.async_add_executor_job(_cache_uname_processor), template.async_load_custom_templates(hass), restore_state.async_load(hass), + hass.config_entries.async_initialize(), ) @@ -270,8 +316,7 @@ async def async_from_config_dict( start = monotonic() hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_initialize() - await load_registries(hass) + await async_load_base_functionality(hass) # Set up core. _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) @@ -478,13 +523,18 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN } - # Add config entry domains + # Add config entry and default domains if not hass.config.recovery_mode: + domains.update(DEFAULT_INTEGRATIONS) domains.update(hass.config_entries.async_domains()) + else: + domains.update(DEFAULT_INTEGRATIONS_RECOVERY_MODE) - # Make sure the Hass.io component is loaded + # Add domains depending on if the Supervisor is used or not if "SUPERVISOR" in os.environ: - domains.add("hassio") + domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR) + else: + domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR) return domains @@ -527,11 +577,13 @@ async def async_setup_multi_components( config: dict[str, Any], ) -> None: """Set up multiple domains. Log on failure.""" + # Avoid creating tasks for domains that were setup in a previous stage + domains_not_yet_setup = domains - hass.config.components futures = { domain: hass.async_create_task( async_setup_component(hass, domain, config), f"setup component {domain}" ) - for domain in domains + for domain in domains_not_yet_setup } results = await asyncio.gather(*futures.values(), return_exceptions=True) for idx, domain in enumerate(futures): @@ -555,6 +607,8 @@ async def _async_set_up_integrations( domains_to_setup = _get_domains(hass, config) + needed_requirements: set[str] = set() + # Resolve all dependencies so we know all integrations # that will have to be loaded and start rightaway integration_cache: dict[str, loader.Integration] = {} @@ -570,6 +624,25 @@ async def _async_set_up_integrations( ).values() if isinstance(int_or_exc, loader.Integration) ] + + manifest_deps: set[str] = set() + for itg in integrations_to_process: + manifest_deps.update(itg.dependencies) + manifest_deps.update(itg.after_dependencies) + needed_requirements.update(itg.requirements) + + if manifest_deps: + # If there are dependencies, try to preload all + # the integrations manifest at once and add them + # to the list of requirements we need to install + # so we can try to check if they are already installed + # in a single call below which avoids each integration + # having to wait for the lock to do it individually + deps = await loader.async_get_integrations(hass, manifest_deps) + for dependant_itg in deps.values(): + if isinstance(dependant_itg, loader.Integration): + needed_requirements.update(dependant_itg.requirements) + resolve_dependencies_tasks = [ itg.resolve_dependencies() for itg in integrations_to_process @@ -591,6 +664,14 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) + # Optimistically check if requirements are already installed + # ahead of setting up the integrations so we can prime the cache + # We do not wait for this since its an optimization only + hass.async_create_background_task( + requirements.async_load_installed_versions(hass, needed_requirements), + "check installed requirements", + ) + # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) diff --git a/homeassistant/brands/govee.json b/homeassistant/brands/govee.json new file mode 100644 index 00000000000..92091d68f58 --- /dev/null +++ b/homeassistant/brands/govee.json @@ -0,0 +1,5 @@ +{ + "domain": "govee", + "name": "Govee", + "integrations": ["govee_ble", "govee_light_local"] +} diff --git a/homeassistant/brands/rainforest.json b/homeassistant/brands/rainforest.json new file mode 100644 index 00000000000..6d04a4bf2d1 --- /dev/null +++ b/homeassistant/brands/rainforest.json @@ -0,0 +1,5 @@ +{ + "domain": "rainforest_automation", + "name": "Rainforest Automation", + "integrations": ["rainforest_eagle", "rainforest_raven"] +} diff --git a/homeassistant/brands/tplink.json b/homeassistant/brands/tplink.json index bc8d38b3e71..06ab621ed32 100644 --- a/homeassistant/brands/tplink.json +++ b/homeassistant/brands/tplink.json @@ -1,6 +1,6 @@ { "domain": "tplink", "name": "TP-Link", - "integrations": ["tplink", "tplink_omada", "tplink_lte"], + "integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"], "iot_standards": ["matter"] } diff --git a/homeassistant/brands/traccar.json b/homeassistant/brands/traccar.json new file mode 100644 index 00000000000..a30c881d978 --- /dev/null +++ b/homeassistant/brands/traccar.json @@ -0,0 +1,5 @@ +{ + "domain": "traccar", + "name": "Traccar", + "integrations": ["traccar", "traccar_server"] +} diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 690b38b4871..839a66af25d 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations 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/__init__.py b/homeassistant/components/abode/__init__.py index 4e4b6a9561d..55ce9e054c3 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -63,12 +63,12 @@ AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, - Platform.LOCK, - Platform.SWITCH, - Platform.COVER, Platform.CAMERA, + Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index d0137395446..4671b71059d 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -17,8 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice, AbodeSystem from .const import DOMAIN -ICON = "mdi:security" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -33,7 +31,6 @@ async def async_setup_entry( class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """An alarm_control_panel implementation for Abode.""" - _attr_icon = ICON _attr_name = None _attr_code_arm_required = False _attr_supported_features = ( diff --git a/homeassistant/components/abode/icons.json b/homeassistant/components/abode/icons.json new file mode 100644 index 00000000000..89cee031818 --- /dev/null +++ b/homeassistant/components/abode/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "automation": { + "default": "mdi:robot" + } + } + } +} diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 14bdf4e0caf..8443a16ef8f 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -17,8 +17,6 @@ from .const import DOMAIN DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE] -ICON = "mdi:robot" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -63,7 +61,7 @@ class AbodeSwitch(AbodeDevice, SwitchEntity): class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): """A switch implementation for Abode automations.""" - _attr_icon = ICON + _attr_translation_key = "automation" async def async_added_to_hass(self) -> None: """Set up trigger automation service.""" diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index e98b19e8e82..dfbf5119981 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -75,7 +75,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching AccuWeather data API.""" def __init__( diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 9ad01ba6f29..5d1f643418a 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -66,12 +66,12 @@ class AcmedaBase(entity.Entity): @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 2af985033b6..32b6cf31ee5 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 @@ class AcmedaCover(AcmedaBase, CoverEntity): @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 ff8f28ffbc3..a87cbcd1635 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 e156ee5cb78..9c6ef6156f0 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 @@ from .helpers import update_devices 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 @@ class PulseHub: 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 @@ class PulseHub: 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/sensor.py b/homeassistant/components/acmeda/sensor.py index e8ccb30ada4..20d0929f341 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/climate.py b/homeassistant/components/adax/climate.py index 34812f9e449..6b0adcb52cf 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -67,9 +67,14 @@ class AdaxDevice(ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = 35 _attr_min_temp = 5 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/adguard/icons.json b/homeassistant/components/adguard/icons.json new file mode 100644 index 00000000000..9c5df8a4a45 --- /dev/null +++ b/homeassistant/components/adguard/icons.json @@ -0,0 +1,75 @@ +{ + "entity": { + "sensor": { + "dns_queries": { + "default": "mdi:magnify" + }, + "dns_queries_blocked": { + "default": "mdi:magnify-close" + }, + "dns_queries_blocked_ratio": { + "default": "mdi:magnify-close" + }, + "parental_control_blocked": { + "default": "mdi:human-male-girl" + }, + "safe_browsing_blocked": { + "default": "mdi:shield-half-full" + }, + "safe_searches_enforced": { + "default": "mdi:shield-search" + }, + "average_processing_speed": { + "default": "mdi:speedometer" + }, + "rules_count": { + "default": "mdi:counter" + } + }, + "switch": { + "protection": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "parental": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "safe_search": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "safe_browsing": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "filtering": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "query_log": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + } + } + }, + "services": { + "add_url": "mdi:link-plus", + "remove_url": "mdi:link-off", + "enable_url": "mdi:link-variant", + "disable_url": "mdi:link-variant-off", + "refresh": "mdi:refresh" + } +} diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index c8ec5023533..e1cec6c4d3b 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -33,56 +33,48 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", translation_key="dns_queries", - icon="mdi:magnify", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.dns_queries(), ), AdGuardHomeEntityDescription( key="blocked_filtering", translation_key="dns_queries_blocked", - icon="mdi:magnify-close", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.blocked_filtering(), ), AdGuardHomeEntityDescription( key="blocked_percentage", translation_key="dns_queries_blocked_ratio", - icon="mdi:magnify-close", native_unit_of_measurement=PERCENTAGE, value_fn=lambda adguard: adguard.stats.blocked_percentage(), ), AdGuardHomeEntityDescription( key="blocked_parental", translation_key="parental_control_blocked", - icon="mdi:human-male-girl", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_parental(), ), AdGuardHomeEntityDescription( key="blocked_safebrowsing", translation_key="safe_browsing_blocked", - icon="mdi:shield-half-full", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safebrowsing(), ), AdGuardHomeEntityDescription( key="enforced_safesearch", translation_key="safe_searches_enforced", - icon="mdi:shield-search", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safesearch(), ), AdGuardHomeEntityDescription( key="average_speed", translation_key="average_processing_speed", - icon="mdi:speedometer", native_unit_of_measurement=UnitOfTime.MILLISECONDS, value_fn=lambda adguard: adguard.stats.avg_processing_time(), ), AdGuardHomeEntityDescription( key="rules_count", translation_key="rules_count", - icon="mdi:counter", native_unit_of_measurement="rules", value_fn=lambda adguard: adguard.filtering.rules_count(allowlist=False), entity_registry_enabled_default=False, diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 4b6fe06cdab..0aa88aa3ffd 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -34,7 +34,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", translation_key="protection", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.protection_enabled, turn_on_fn=lambda adguard: adguard.enable_protection, turn_off_fn=lambda adguard: adguard.disable_protection, @@ -42,7 +41,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="parental", translation_key="parental", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.parental.enabled, turn_on_fn=lambda adguard: adguard.parental.enable, turn_off_fn=lambda adguard: adguard.parental.disable, @@ -50,7 +48,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="safesearch", translation_key="safe_search", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safesearch.enabled, turn_on_fn=lambda adguard: adguard.safesearch.enable, turn_off_fn=lambda adguard: adguard.safesearch.disable, @@ -58,7 +55,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="safebrowsing", translation_key="safe_browsing", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safebrowsing.enabled, turn_on_fn=lambda adguard: adguard.safebrowsing.enable, turn_off_fn=lambda adguard: adguard.safebrowsing.disable, @@ -66,7 +62,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="filtering", translation_key="filtering", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.filtering.enabled, turn_on_fn=lambda adguard: adguard.filtering.enable, turn_off_fn=lambda adguard: adguard.filtering.disable, @@ -74,7 +69,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="querylog", translation_key="query_log", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.querylog.enabled, turn_on_fn=lambda adguard: adguard.querylog.enable, turn_off_fn=lambda adguard: adguard.querylog.disable, diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 1383ea7c054..0ef2c0eada5 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -19,11 +19,11 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.LIGHT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, - Platform.LIGHT, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a488ba8b362..870a001a10f 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -83,12 +83,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False 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_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._attr_hvac_modes = [ HVACMode.OFF, HVACMode.COOL, @@ -198,11 +203,16 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """AdvantageAir MyTemp Zone control.""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 + _enable_turn_on_off_backwards_compatibility = False def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index d0aca153d4c..47c8c7c1768 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -40,6 +40,7 @@ async def async_setup_entry( class AdvantageAirLight(AdvantageAirEntity, LightEntity): """Representation of Advantage Air Light.""" + _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None @@ -82,7 +83,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): class AdvantageAirLightDimmable(AdvantageAirLight): """Representation of Advantage Air Dimmable Light.""" - _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: """Initialize an Advantage Air Dimmable Light.""" @@ -106,13 +108,15 @@ class AdvantageAirLightDimmable(AdvantageAirLight): class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity): """Representation of Advantage Air Light controlled by myThings.""" + _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity): """Representation of Advantage Air Dimmable Light controlled by myThings.""" - _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @property def brightness(self) -> int: diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 843693d2dc3..5c288b206d0 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -18,7 +18,7 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, PLATFORMS, ) -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c3328fc1b5d..6b11e6aa70f 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -59,8 +59,6 @@ ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_CONDITION = "condition" -ATTR_API_FORECAST_DAILY = "forecast-daily" -ATTR_API_FORECAST_HOURLY = "forecast-hourly" ATTR_API_FORECAST_PRECIPITATION = "precipitation" ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" ATTR_API_FORECAST_TEMP = "temperature" @@ -101,49 +99,6 @@ CONDITIONS_MAP = { AOD_COND_SUNNY: ATTR_CONDITION_SUNNY, } -FORECAST_MONITORED_CONDITIONS = [ - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, -] -MONITORED_CONDITIONS = [ - ATTR_API_CONDITION, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_RAIN, - ATTR_API_RAIN_PROB, - ATTR_API_SNOW, - ATTR_API_SNOW_PROB, - ATTR_API_STATION_ID, - ATTR_API_STATION_NAME, - ATTR_API_STATION_TIMESTAMP, - ATTR_API_STORM_PROB, - ATTR_API_TEMPERATURE, - ATTR_API_TEMPERATURE_FEELING, - ATTR_API_TOWN_ID, - ATTR_API_TOWN_NAME, - ATTR_API_TOWN_TIMESTAMP, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, -] - -FORECAST_MODE_DAILY = "daily" -FORECAST_MODE_HOURLY = "hourly" -FORECAST_MODES = [ - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, -] -FORECAST_MODE_ATTR_API = { - FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, - FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, -} - FORECAST_MAP = { AOD_FORECAST_DAILY: { AOD_CONDITION: ATTR_FORECAST_CONDITION, diff --git a/homeassistant/components/aemet/coordinator.py b/homeassistant/components/aemet/coordinator.py new file mode 100644 index 00000000000..04810077f28 --- /dev/null +++ b/homeassistant/components/aemet/coordinator.py @@ -0,0 +1,85 @@ +"""Weather data coordinator for the AEMET OpenData service.""" +from __future__ import annotations + +from asyncio import timeout +from datetime import timedelta +import logging +from typing import Any, Final, cast + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FORECAST, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_TOWN, +) +from aemet_opendata.exceptions import AemetError +from aemet_opendata.helpers import dict_nested_value +from aemet_opendata.interface import AEMET + +from homeassistant.components.weather import Forecast +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONDITIONS_MAP, DOMAIN, FORECAST_MAP + +_LOGGER = logging.getLogger(__name__) + +API_TIMEOUT: Final[int] = 120 +WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) + + +class WeatherUpdateCoordinator(DataUpdateCoordinator): + """Weather data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + aemet: AEMET, + ) -> None: + """Initialize coordinator.""" + self.aemet = aemet + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=WEATHER_UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update coordinator data.""" + async with timeout(API_TIMEOUT): + try: + await self.aemet.update() + except AemetError as error: + raise UpdateFailed(error) from error + + data = self.aemet.data() + + return { + "forecast": { + AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), + AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), + }, + "lib": data, + } + + def aemet_forecast( + self, + data: dict[str, Any], + forecast_mode: str, + ) -> list[Forecast]: + """Return the forecast array.""" + forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST]) + forecast_map = FORECAST_MAP[forecast_mode] + forecast_list: list[dict[str, Any]] = [] + for forecast in forecasts: + cur_forecast: dict[str, Any] = {} + for api_key, ha_key in forecast_map.items(): + value = forecast[api_key] + if api_key == AOD_CONDITION: + value = CONDITIONS_MAP.get(value) + cur_forecast[ha_key] = value + forecast_list += [cur_forecast] + return cast(list[Forecast], forecast_list) diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py index 527ff046104..b83c0c98807 100644 --- a/homeassistant/components/aemet/entity.py +++ b/homeassistant/components/aemet/entity.py @@ -8,7 +8,7 @@ from aemet_opendata.helpers import dict_nested_value from homeassistant.components.weather import Forecast from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 76e691a4682..f51bdcf765a 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,6 +1,41 @@ """Support for the AEMET OpenData service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FEEL_TEMP, + AOD_FORECAST_CURRENT, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_HUMIDITY, + AOD_ID, + AOD_NAME, + AOD_PRECIPITATION, + AOD_PRECIPITATION_PROBABILITY, + AOD_PRESSURE, + AOD_RAIN, + AOD_RAIN_PROBABILITY, + AOD_SNOW, + AOD_SNOW_PROBABILITY, + AOD_STATION, + AOD_STORM_PROBABILITY, + AOD_TEMP, + AOD_TEMP_MAX, + AOD_TEMP_MIN, + AOD_TIMESTAMP, + AOD_TOWN, + AOD_WEATHER, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, +) +from aemet_opendata.helpers import dict_nested_value + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -18,7 +53,6 @@ from homeassistant.const import ( ) 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 .const import ( @@ -51,172 +85,270 @@ from .const import ( ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_SPEED, ATTRIBUTION, + CONDITIONS_MAP, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODE_ATTR_API, - FORECAST_MODE_DAILY, - FORECAST_MODES, - FORECAST_MONITORED_CONDITIONS, - MONITORED_CONDITIONS, ) -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator +from .entity import AemetEntity -FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=ATTR_API_FORECAST_CONDITION, - name="Condition", + +@dataclass(frozen=True, kw_only=True) +class AemetSensorEntityDescription(SensorEntityDescription): + """A class that describes AEMET OpenData sensor entities.""" + + keys: list[str] | None = None + value_fn: Callable[[str], datetime | float | int | str | None] = lambda value: value + + +FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_CONDITION}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_CONDITION], + name="Daily forecast condition", + value_fn=CONDITIONS_MAP.get, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION, - name="Precipitation", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_CONDITION}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_CONDITION], + name="Hourly forecast condition", + value_fn=CONDITIONS_MAP.get, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_PRECIPITATION], + name="Hourly forecast precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - name="Precipitation probability", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}", + keys=[ + AOD_TOWN, + AOD_FORECAST_DAILY, + AOD_FORECAST_CURRENT, + AOD_PRECIPITATION_PROBABILITY, + ], + name="Daily forecast precipitation probability", native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP, - name="Temperature", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}", + keys=[ + AOD_TOWN, + AOD_FORECAST_HOURLY, + AOD_FORECAST_CURRENT, + AOD_PRECIPITATION_PROBABILITY, + ], + name="Hourly forecast precipitation probability", + native_unit_of_measurement=PERCENTAGE, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TEMP}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MAX], + name="Daily forecast temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP_LOW, - name="Temperature Low", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TEMP_LOW}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MIN], + name="Daily forecast temperature low", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TIME, - name="Time", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_TEMP}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TEMP], + name="Hourly forecast temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TIME}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + name="Daily forecast time", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_BEARING, - name="Wind bearing", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_TIME}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + name="Hourly forecast time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_WIND_BEARING}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], + name="Daily forecast wind bearing", native_unit_of_measurement=DEGREE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_MAX_SPEED, - name="Wind max speed", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_BEARING}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], + name="Hourly forecast wind bearing", + native_unit_of_measurement=DEGREE, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_MAX_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED_MAX], + name="Hourly forecast wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_SPEED, - name="Wind speed", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_WIND_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED], + name="Daily forecast wind speed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED], + name="Hourly forecast wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), ) -WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + + +WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( + AemetSensorEntityDescription( key=ATTR_API_CONDITION, + keys=[AOD_WEATHER, AOD_CONDITION], name="Condition", + value_fn=CONDITIONS_MAP.get, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_HUMIDITY, + keys=[AOD_WEATHER, AOD_HUMIDITY], name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_PRESSURE, + keys=[AOD_WEATHER, AOD_PRESSURE], name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_RAIN, + keys=[AOD_WEATHER, AOD_RAIN], name="Rain", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_RAIN_PROB, + keys=[AOD_WEATHER, AOD_RAIN_PROBABILITY], name="Rain probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_SNOW, + keys=[AOD_WEATHER, AOD_SNOW], name="Snow", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_SNOW_PROB, + keys=[AOD_WEATHER, AOD_SNOW_PROBABILITY], name="Snow probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_ID, + keys=[AOD_STATION, AOD_ID], name="Station ID", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_NAME, + keys=[AOD_STATION, AOD_NAME], name="Station name", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_TIMESTAMP, + keys=[AOD_STATION, AOD_TIMESTAMP], name="Station timestamp", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STORM_PROB, + keys=[AOD_WEATHER, AOD_STORM_PROBABILITY], name="Storm probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TEMPERATURE, + keys=[AOD_WEATHER, AOD_TEMP], name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TEMPERATURE_FEELING, + keys=[AOD_WEATHER, AOD_FEEL_TEMP], name="Temperature feeling", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_ID, + keys=[AOD_TOWN, AOD_ID], name="Town ID", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_NAME, + keys=[AOD_TOWN, AOD_NAME], name="Town name", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_TIMESTAMP, + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP], name="Town timestamp", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_BEARING, + keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_MAX_SPEED, + keys=[AOD_WEATHER, AOD_WIND_SPEED_MAX], name="Wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_SPEED, + keys=[AOD_WEATHER, AOD_WIND_SPEED], name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, @@ -232,108 +364,46 @@ async def async_setup_entry( ) -> None: """Set up AEMET OpenData sensor entities based on a config entry.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] - name = domain_data[ENTRY_NAME] - weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + name: str = domain_data[ENTRY_NAME] + coordinator: WeatherUpdateCoordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - unique_id = config_entry.unique_id - entities: list[AbstractAemetSensor] = [ - AemetSensor(name, unique_id, weather_coordinator, description) - for description in WEATHER_SENSOR_TYPES - if description.key in MONITORED_CONDITIONS - ] - entities.extend( - [ - AemetForecastSensor( - f"{domain_data[ENTRY_NAME]} {mode} Forecast", - f"{unique_id}-forecast-{mode}", - weather_coordinator, - mode, - description, + entities: list[AemetSensor] = [] + + for description in FORECAST_SENSORS + WEATHER_SENSORS: + if dict_nested_value(coordinator.data["lib"], description.keys) is not None: + entities.append( + AemetSensor( + name, + coordinator, + description, + config_entry, + ) ) - for mode in FORECAST_MODES - for description in FORECAST_SENSOR_TYPES - if description.key in FORECAST_MONITORED_CONDITIONS - ] - ) async_add_entities(entities) -class AbstractAemetSensor(CoordinatorEntity[WeatherUpdateCoordinator], SensorEntity): - """Abstract class for an AEMET OpenData sensor.""" +class AemetSensor(AemetEntity, SensorEntity): + """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION + entity_description: AemetSensorEntityDescription def __init__( self, - name, - unique_id, + name: str, coordinator: WeatherUpdateCoordinator, - description: SensorEntityDescription, + description: AemetSensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - - -class AemetSensor(AbstractAemetSensor): - """Implementation of an AEMET OpenData sensor.""" - - def __init__( - self, - name, - unique_id_prefix, - weather_coordinator: WeatherUpdateCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__( - name=name, - unique_id=f"{unique_id_prefix}-{description.key}", - coordinator=weather_coordinator, - description=description, - ) + self._attr_unique_id = f"{config_entry.unique_id}-{description.key}" @property def native_value(self): """Return the state of the device.""" - return self.coordinator.data.get(self.entity_description.key) - - -class AemetForecastSensor(AbstractAemetSensor): - """Implementation of an AEMET OpenData forecast sensor.""" - - def __init__( - self, - name, - unique_id_prefix, - weather_coordinator: WeatherUpdateCoordinator, - forecast_mode, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__( - name=name, - unique_id=f"{unique_id_prefix}-{description.key}", - coordinator=weather_coordinator, - description=description, - ) - self._forecast_mode = forecast_mode - self._attr_entity_registry_enabled_default = ( - self._forecast_mode == FORECAST_MODE_DAILY - ) - - @property - def native_value(self): - """Return the state of the device.""" - forecast = None - forecasts = self.coordinator.data.get( - FORECAST_MODE_ATTR_API[self._forecast_mode] - ) - if forecasts: - forecast = forecasts[0].get(self.entity_description.key) - if self.entity_description.key == ATTR_API_FORECAST_TIME: - forecast = dt_util.parse_datetime(forecast) - return forecast + value = self.get_aemet_value(self.entity_description.keys) + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index b7b3c31ab5b..d49b62c9509 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -38,8 +38,8 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, WEATHER_FORECAST_MODES, ) +from .coordinator import WeatherUpdateCoordinator from .entity import AemetEntity -from .weather_update_coordinator import WeatherUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py deleted file mode 100644 index cd95a8e0854..00000000000 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ /dev/null @@ -1,558 +0,0 @@ -"""Weather data coordinator for the AEMET OpenData service.""" -from __future__ import annotations - -from asyncio import timeout -from datetime import timedelta -import logging -from typing import Any, Final, cast - -from aemet_opendata.const import ( - AEMET_ATTR_DATE, - AEMET_ATTR_DAY, - AEMET_ATTR_DIRECTION, - AEMET_ATTR_ELABORATED, - AEMET_ATTR_FEEL_TEMPERATURE, - AEMET_ATTR_FORECAST, - AEMET_ATTR_HUMIDITY, - AEMET_ATTR_MAX, - AEMET_ATTR_MIN, - AEMET_ATTR_PRECIPITATION, - AEMET_ATTR_PRECIPITATION_PROBABILITY, - AEMET_ATTR_SKY_STATE, - AEMET_ATTR_SNOW, - AEMET_ATTR_SNOW_PROBABILITY, - AEMET_ATTR_SPEED, - AEMET_ATTR_STATION_DATE, - AEMET_ATTR_STATION_HUMIDITY, - AEMET_ATTR_STATION_PRESSURE, - AEMET_ATTR_STATION_PRESSURE_SEA, - AEMET_ATTR_STATION_TEMPERATURE, - AEMET_ATTR_STORM_PROBABILITY, - AEMET_ATTR_TEMPERATURE, - AEMET_ATTR_WIND, - AEMET_ATTR_WIND_GUST, - AOD_CONDITION, - AOD_FORECAST, - AOD_FORECAST_DAILY, - AOD_FORECAST_HOURLY, - AOD_TOWN, - ATTR_DATA, -) -from aemet_opendata.exceptions import AemetError -from aemet_opendata.forecast import ForecastValue -from aemet_opendata.helpers import ( - dict_nested_value, - get_forecast_day_value, - get_forecast_hour_value, - get_forecast_interval_value, -) -from aemet_opendata.interface import AEMET - -from homeassistant.components.weather import Forecast -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util - -from .const import ( - ATTR_API_CONDITION, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_DAILY, - ATTR_API_FORECAST_HOURLY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_RAIN, - ATTR_API_RAIN_PROB, - ATTR_API_SNOW, - ATTR_API_SNOW_PROB, - ATTR_API_STATION_ID, - ATTR_API_STATION_NAME, - ATTR_API_STATION_TIMESTAMP, - ATTR_API_STORM_PROB, - ATTR_API_TEMPERATURE, - ATTR_API_TEMPERATURE_FEELING, - ATTR_API_TOWN_ID, - ATTR_API_TOWN_NAME, - ATTR_API_TOWN_TIMESTAMP, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, - CONDITIONS_MAP, - DOMAIN, - FORECAST_MAP, -) - -_LOGGER = logging.getLogger(__name__) - -API_TIMEOUT: Final[int] = 120 -STATION_MAX_DELTA = timedelta(hours=2) -WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) - - -def format_condition(condition: str) -> str: - """Return condition from dict CONDITIONS_MAP.""" - val = ForecastValue.parse_condition(condition) - return CONDITIONS_MAP.get(val, val) - - -def format_float(value) -> float | None: - """Try converting string to float.""" - try: - return float(value) - except (TypeError, ValueError): - return None - - -def format_int(value) -> int | None: - """Try converting string to int.""" - try: - return int(value) - except (TypeError, ValueError): - return None - - -class WeatherUpdateCoordinator(DataUpdateCoordinator): - """Weather data update coordinator.""" - - def __init__( - self, - hass: HomeAssistant, - aemet: AEMET, - ) -> None: - """Initialize coordinator.""" - self.aemet = aemet - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=WEATHER_UPDATE_INTERVAL, - ) - - async def _async_update_data(self) -> dict[str, Any]: - """Update coordinator data.""" - async with timeout(API_TIMEOUT): - try: - await self.aemet.update() - except AemetError as error: - raise UpdateFailed(error) from error - weather_response = self.aemet.legacy_weather() - return self._convert_weather_response(weather_response) - - def _convert_weather_response(self, weather_response): - """Format the weather response correctly.""" - if not weather_response or not weather_response.hourly: - return None - - elaborated = dt_util.parse_datetime( - weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_ELABORATED] + "Z" - ) - now = dt_util.now() - now_utc = dt_util.utcnow() - hour = now.hour - - # Get current day - day = None - for cur_day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - cur_day_date = dt_util.parse_datetime(cur_day[AEMET_ATTR_DATE]) - if now.date() == cur_day_date.date(): - day = cur_day - break - - # Get latest station data - station_data = None - station_dt = None - if weather_response.station: - for _station_data in weather_response.station[ATTR_DATA]: - if AEMET_ATTR_STATION_DATE in _station_data: - _station_dt = dt_util.parse_datetime( - _station_data[AEMET_ATTR_STATION_DATE] + "Z" - ) - if not station_dt or _station_dt > station_dt: - station_data = _station_data - station_dt = _station_dt - - condition = None - humidity = None - pressure = None - rain = None - rain_prob = None - snow = None - snow_prob = None - station_id = None - station_name = None - station_timestamp = None - storm_prob = None - temperature = None - temperature_feeling = None - town_id = None - town_name = None - town_timestamp = dt_util.as_utc(elaborated) - wind_bearing = None - wind_max_speed = None - wind_speed = None - - # Get weather values - if day: - condition = self._get_condition(day, hour) - humidity = self._get_humidity(day, hour) - rain = self._get_rain(day, hour) - rain_prob = self._get_rain_prob(day, hour) - snow = self._get_snow(day, hour) - snow_prob = self._get_snow_prob(day, hour) - station_id = self._get_station_id() - station_name = self._get_station_name() - storm_prob = self._get_storm_prob(day, hour) - temperature = self._get_temperature(day, hour) - temperature_feeling = self._get_temperature_feeling(day, hour) - town_id = self._get_town_id() - town_name = self._get_town_name() - wind_bearing = self._get_wind_bearing(day, hour) - wind_max_speed = self._get_wind_max_speed(day, hour) - wind_speed = self._get_wind_speed(day, hour) - - # Overwrite weather values with closest station data (if present) - if station_data: - station_timestamp = dt_util.as_utc(station_dt) - if (now_utc - station_dt) <= STATION_MAX_DELTA: - if AEMET_ATTR_STATION_HUMIDITY in station_data: - humidity = format_float(station_data[AEMET_ATTR_STATION_HUMIDITY]) - if AEMET_ATTR_STATION_PRESSURE_SEA in station_data: - pressure = format_float( - station_data[AEMET_ATTR_STATION_PRESSURE_SEA] - ) - elif AEMET_ATTR_STATION_PRESSURE in station_data: - pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE]) - if AEMET_ATTR_STATION_TEMPERATURE in station_data: - temperature = format_float( - station_data[AEMET_ATTR_STATION_TEMPERATURE] - ) - else: - _LOGGER.warning("Station data is outdated") - - # Get forecast from weather data - forecast_daily = self._get_daily_forecast_from_weather_response( - weather_response, now - ) - forecast_hourly = self._get_hourly_forecast_from_weather_response( - weather_response, now - ) - - data = self.aemet.data() - forecasts: list[dict[str, Forecast]] = { - AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), - AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), - } - - return { - ATTR_API_CONDITION: condition, - ATTR_API_FORECAST_DAILY: forecast_daily, - ATTR_API_FORECAST_HOURLY: forecast_hourly, - ATTR_API_HUMIDITY: humidity, - ATTR_API_TEMPERATURE: temperature, - ATTR_API_TEMPERATURE_FEELING: temperature_feeling, - ATTR_API_PRESSURE: pressure, - ATTR_API_RAIN: rain, - ATTR_API_RAIN_PROB: rain_prob, - ATTR_API_SNOW: snow, - ATTR_API_SNOW_PROB: snow_prob, - ATTR_API_STATION_ID: station_id, - ATTR_API_STATION_NAME: station_name, - ATTR_API_STATION_TIMESTAMP: station_timestamp, - ATTR_API_STORM_PROB: storm_prob, - ATTR_API_TOWN_ID: town_id, - ATTR_API_TOWN_NAME: town_name, - ATTR_API_TOWN_TIMESTAMP: town_timestamp, - ATTR_API_WIND_BEARING: wind_bearing, - ATTR_API_WIND_MAX_SPEED: wind_max_speed, - ATTR_API_WIND_SPEED: wind_speed, - "forecast": forecasts, - "lib": data, - } - - def aemet_forecast( - self, - data: dict[str, Any], - forecast_mode: str, - ) -> list[Forecast]: - """Return the forecast array.""" - forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST]) - forecast_map = FORECAST_MAP[forecast_mode] - forecast_list: list[dict[str, Any]] = [] - for forecast in forecasts: - cur_forecast: dict[str, Any] = {} - for api_key, ha_key in forecast_map.items(): - value = forecast[api_key] - if api_key == AOD_CONDITION: - value = CONDITIONS_MAP.get(value) - cur_forecast[ha_key] = value - forecast_list += [cur_forecast] - return cast(list[Forecast], forecast_list) - - def _get_daily_forecast_from_weather_response(self, weather_response, now): - if weather_response.daily: - parse = False - forecast = [] - for day in weather_response.daily[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) - if now.date() == day_date.date(): - parse = True - if parse: - cur_forecast = self._convert_forecast_day(day_date, day) - if cur_forecast: - forecast.append(cur_forecast) - return forecast - return None - - def _get_hourly_forecast_from_weather_response(self, weather_response, now): - if weather_response.hourly: - parse = False - hour = now.hour - forecast = [] - for day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) - hour_start = 0 - if now.date() == day_date.date(): - parse = True - hour_start = now.hour - if parse: - for hour in range(hour_start, 24): - cur_forecast = self._convert_forecast_hour(day_date, day, hour) - if cur_forecast: - forecast.append(cur_forecast) - return forecast - return None - - def _convert_forecast_day(self, date, day): - if not (condition := self._get_condition_day(day)): - return None - - return { - ATTR_API_FORECAST_CONDITION: condition, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( - day - ), - ATTR_API_FORECAST_TEMP: self._get_temperature_day(day), - ATTR_API_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), - ATTR_API_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), - ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), - } - - def _convert_forecast_hour(self, date, day, hour): - if not (condition := self._get_condition(day, hour)): - return None - - forecast_dt = date.replace(hour=hour, minute=0, second=0) - - return { - ATTR_API_FORECAST_CONDITION: condition, - ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( - day, hour - ), - ATTR_API_FORECAST_TEMP: self._get_temperature(day, hour), - ATTR_API_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_API_FORECAST_WIND_MAX_SPEED: self._get_wind_max_speed(day, hour), - ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), - ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), - } - - def _calc_precipitation(self, day, hour): - """Calculate the precipitation.""" - rain_value = self._get_rain(day, hour) or 0 - snow_value = self._get_snow(day, hour) or 0 - - if round(rain_value + snow_value, 1) == 0: - return None - return round(rain_value + snow_value, 1) - - def _calc_precipitation_prob(self, day, hour): - """Calculate the precipitation probability (hour).""" - rain_value = self._get_rain_prob(day, hour) or 0 - snow_value = self._get_snow_prob(day, hour) or 0 - - if rain_value == 0 and snow_value == 0: - return None - return max(rain_value, snow_value) - - @staticmethod - def _get_condition(day_data, hour): - """Get weather condition (hour) from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_SKY_STATE], hour) - if val: - return format_condition(val) - return None - - @staticmethod - def _get_condition_day(day_data): - """Get weather condition (day) from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_SKY_STATE]) - if val: - return format_condition(val) - return None - - @staticmethod - def _get_humidity(day_data, hour): - """Get humidity from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_HUMIDITY], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_precipitation_prob_day(day_data): - """Get humidity from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY]) - if val: - return format_int(val) - return None - - @staticmethod - def _get_rain(day_data, hour): - """Get rain from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_PRECIPITATION], hour) - if val: - return format_float(val) - return None - - @staticmethod - def _get_rain_prob(day_data, hour): - """Get rain probability from weather data.""" - val = get_forecast_interval_value( - day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY], hour - ) - if val: - return format_int(val) - return None - - @staticmethod - def _get_snow(day_data, hour): - """Get snow from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_SNOW], hour) - if val: - return format_float(val) - return None - - @staticmethod - def _get_snow_prob(day_data, hour): - """Get snow probability from weather data.""" - val = get_forecast_interval_value(day_data[AEMET_ATTR_SNOW_PROBABILITY], hour) - if val: - return format_int(val) - return None - - def _get_station_id(self): - """Get station ID from weather data.""" - if self.aemet.station: - return self.aemet.station.get_id() - return None - - def _get_station_name(self): - """Get station name from weather data.""" - if self.aemet.station: - return self.aemet.station.get_name() - return None - - @staticmethod - def _get_storm_prob(day_data, hour): - """Get storm probability from weather data.""" - val = get_forecast_interval_value(day_data[AEMET_ATTR_STORM_PROBABILITY], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_temperature(day_data, hour): - """Get temperature (hour) from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE], hour) - return format_int(val) - - @staticmethod - def _get_temperature_day(day_data): - """Get temperature (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MAX - ) - return format_int(val) - - @staticmethod - def _get_temperature_low_day(day_data): - """Get temperature (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MIN - ) - return format_int(val) - - @staticmethod - def _get_temperature_feeling(day_data, hour): - """Get temperature from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_FEEL_TEMPERATURE], hour) - return format_int(val) - - def _get_town_id(self): - """Get town ID from weather data.""" - if self.aemet.town: - return self.aemet.town.get_id() - return None - - def _get_town_name(self): - """Get town name from weather data.""" - if self.aemet.town: - return self.aemet.town.get_name() - return None - - @staticmethod - def _get_wind_bearing(day_data, hour): - """Get wind bearing (hour) from weather data.""" - val = get_forecast_hour_value( - day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION - )[0] - return ForecastValue.parse_wind_direction(val) - - @staticmethod - def _get_wind_bearing_day(day_data): - """Get wind bearing (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION - ) - return ForecastValue.parse_wind_direction(val) - - @staticmethod - def _get_wind_max_speed(day_data, hour): - """Get wind max speed from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_WIND_GUST], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_wind_speed(day_data, hour): - """Get wind speed (hour) from weather data.""" - val = get_forecast_hour_value( - day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_SPEED - )[0] - if val: - return format_int(val) - return None - - @staticmethod - def _get_wind_speed_day(day_data): - """Get wind speed (day) from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_SPEED) - if val: - return format_int(val) - return None diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 1ac26e2eb79..9e5586b21f4 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -18,8 +18,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONNECTION, DOMAIN as AGENT_DOMAIN -ICON = "mdi:security" - CONF_HOME_MODE_NAME = "home" CONF_AWAY_MODE_NAME = "away" CONF_NIGHT_MODE_NAME = "night" @@ -41,7 +39,6 @@ async def async_setup_entry( class AgentBaseStation(AlarmControlPanelEntity): """Representation of an Agent DVR Alarm Control Panel.""" - _attr_icon = ICON _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/air_quality/icons.json b/homeassistant/components/air_quality/icons.json new file mode 100644 index 00000000000..f15cb508ba8 --- /dev/null +++ b/homeassistant/components/air_quality/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:air-filter" + } + } +} diff --git a/homeassistant/components/airly/icons.json b/homeassistant/components/airly/icons.json new file mode 100644 index 00000000000..646953014d0 --- /dev/null +++ b/homeassistant/components/airly/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "caqi": { + "default": "mdi:air-filter" + } + } + } +} diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 6105b277088..f91a242b8d5 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -66,7 +66,6 @@ class AirlySensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, - icon="mdi:air-filter", translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, diff --git a/homeassistant/components/airnow/icons.json b/homeassistant/components/airnow/icons.json new file mode 100644 index 00000000000..0815109b6e9 --- /dev/null +++ b/homeassistant/components/airnow/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "aqi": { + "default": "mdi:blur" + }, + "pm25": { + "default": "mdi:blur" + }, + "o3": { + "default": "mdi:blur" + }, + "station": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 9c154dc0712..bfe9e92c4a3 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -77,7 +77,7 @@ def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, - icon="mdi:blur", + translation_key="aqi", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.AQI, value_fn=lambda data: data.get(ATTR_API_AQI), @@ -94,7 +94,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( ), AirNowEntityDescription( key=ATTR_API_PM25, - icon="mdi:blur", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PM25, @@ -104,7 +104,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_O3, translation_key="o3", - icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get(ATTR_API_O3), @@ -113,7 +112,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_STATION, translation_key="station", - icon="mdi:blur", value_fn=lambda data: data.get(ATTR_API_STATION), extra_state_attributes_fn=station_extra_attrs, ), diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 76459005c45..6f49303bc6c 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -56,4 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - return await self.airq.get_latest_data() + return await self.airq.get_latest_data() # type: ignore[no-any-return] diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json new file mode 100644 index 00000000000..fec6eb8dd86 --- /dev/null +++ b/homeassistant/components/airq/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "health_index": { + "default": "mdi:heart-pulse" + }, + "absolute_humidity": { + "default": "mdi:water" + }, + "oxygen": { + "default": "mdi:leaf" + }, + "performance_index": { + "default": "mdi:head-check" + }, + "radon": { + "default": "mdi:radioactive" + }, + "virus_index": { + "default": "mdi:virus-off" + } + } + } +} diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index f1fdfb289dd..ad05202943f 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -190,7 +190,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="health_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:heart-pulse", value=lambda data: data.get("health", 0.0) / 10.0, ), AirQEntityDescription( @@ -206,7 +205,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), - icon="mdi:water", ), AirQEntityDescription( key="h2_M1000", @@ -263,7 +261,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("oxygen"), - icon="mdi:leaf", ), AirQEntityDescription( key="o3", @@ -277,7 +274,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="performance_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), AirQEntityDescription( @@ -293,7 +289,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm1"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pm2_5", @@ -301,7 +296,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm2_5"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pm10", @@ -309,7 +303,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm10"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pressure", @@ -376,7 +369,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("radon"), - icon="mdi:radioactive", ), AirQEntityDescription( key="temperature", @@ -405,7 +397,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="virus_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:virus-off", value=lambda data: data.get("virus", 0.0), ), ] diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index d596c1db757..a5b962d1bf7 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations 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 CONF_ID, Platform @@ -19,6 +19,8 @@ _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/manifest.json b/homeassistant/components/airthings/manifest.json index da7f30679c6..67057ff09f5 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -1,10 +1,10 @@ { "domain": "airthings", "name": "Airthings", - "codeowners": ["@danielhiversen"], + "codeowners": ["@danielhiversen", "@LaStrada"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], - "requirements": ["airthings-cloud==0.1.0"] + "requirements": ["airthings-cloud==0.2.0"] } diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index cd4e9d52f6b..9d772d11996 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -24,11 +24,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from 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: @@ -149,10 +149,10 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", - model=airthings_device.device_type.replace("_", " ").lower().title(), + model=airthings_device.product_name, ) @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/icons.json b/homeassistant/components/airthings_ble/icons.json new file mode 100644 index 00000000000..4cc618ef98c --- /dev/null +++ b/homeassistant/components/airthings_ble/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "radon_1day_avg": { + "default": "mdi:radioactive" + }, + "radon_longterm_avg": { + "default": "mdi:radioactive" + }, + "radon_1day_level": { + "default": "mdi:radioactive" + }, + "radon_longterm_level": { + "default": "mdi:radioactive" + } + } + } +} diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 03b42410d66..97e27793da2 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.6.0"] + "requirements": ["airthings-ble==0.6.1"] } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index c4797713bb8..39c55e0b465 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -52,24 +52,20 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { translation_key="radon_1day_avg", native_unit_of_measurement=VOLUME_BECQUEREL, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:radioactive", ), "radon_longterm_avg": SensorEntityDescription( key="radon_longterm_avg", translation_key="radon_longterm_avg", native_unit_of_measurement=VOLUME_BECQUEREL, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:radioactive", ), "radon_1day_level": SensorEntityDescription( key="radon_1day_level", translation_key="radon_1day_level", - icon="mdi:radioactive", ), "radon_longterm_level": SensorEntityDescription( key="radon_longterm_level", translation_key="radon_longterm_level", - icon="mdi:radioactive", ), "temperature": SensorEntityDescription( key="temperature", @@ -107,7 +103,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:cloud", ), "illuminance": SensorEntityDescription( key="illuminance", diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index bd1c481ce65..89afddad76e 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): _attr_name = None _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" @@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" diff --git a/homeassistant/components/airtouch5/__init__.py b/homeassistant/components/airtouch5/__init__.py new file mode 100644 index 00000000000..6ec32eaa021 --- /dev/null +++ b/homeassistant/components/airtouch5/__init__.py @@ -0,0 +1,50 @@ +"""The Airtouch 5 integration.""" +from __future__ import annotations + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, 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 Airtouch 5 from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + # Create API instance + host = entry.data[CONF_HOST] + client = Airtouch5SimpleClient(host) + + # Connect to the API + try: + await client.connect_and_stay_connected() + except TimeoutError as t: + raise ConfigEntryNotReady() from t + + # Store an API object for your platforms to access + hass.data[DOMAIN][entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client: Airtouch5SimpleClient = hass.data[DOMAIN][entry.entry_id] + await client.disconnect() + client.ac_status_callbacks.clear() + client.connection_state_callbacks.clear() + client.data_packet_callbacks.clear() + client.zone_status_callbacks.clear() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py new file mode 100644 index 00000000000..ee92f68c0ed --- /dev/null +++ b/homeassistant/components/airtouch5/climate.py @@ -0,0 +1,379 @@ +"""AirTouch 5 component to control AirTouch 5 Climate Devices.""" +import logging +from typing import Any + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient +from airtouch5py.packets.ac_ability import AcAbility +from airtouch5py.packets.ac_control import ( + AcControl, + SetAcFanSpeed, + SetAcMode, + SetpointControl, + SetPowerSetting, +) +from airtouch5py.packets.ac_status import AcFanSpeed, AcMode, AcPowerState, AcStatus +from airtouch5py.packets.zone_control import ( + ZoneControlZone, + ZoneSettingPower, + ZoneSettingValue, +) +from airtouch5py.packets.zone_name import ZoneName +from airtouch5py.packets.zone_status import ZonePowerState, ZoneStatusZone + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_BOOST, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FAN_INTELLIGENT_AUTO, FAN_TURBO +from .entity import Airtouch5Entity + +_LOGGER = logging.getLogger(__name__) + +AC_MODE_TO_HVAC_MODE = { + AcMode.AUTO: HVACMode.AUTO, + AcMode.AUTO_COOL: HVACMode.AUTO, + AcMode.AUTO_HEAT: HVACMode.AUTO, + AcMode.COOL: HVACMode.COOL, + AcMode.DRY: HVACMode.DRY, + AcMode.FAN: HVACMode.FAN_ONLY, + AcMode.HEAT: HVACMode.HEAT, +} +HVAC_MODE_TO_SET_AC_MODE = { + HVACMode.AUTO: SetAcMode.SET_TO_AUTO, + HVACMode.COOL: SetAcMode.SET_TO_COOL, + HVACMode.DRY: SetAcMode.SET_TO_DRY, + HVACMode.FAN_ONLY: SetAcMode.SET_TO_FAN, + HVACMode.HEAT: SetAcMode.SET_TO_HEAT, +} + + +AC_FAN_SPEED_TO_FAN_SPEED = { + AcFanSpeed.AUTO: FAN_AUTO, + AcFanSpeed.QUIET: FAN_DIFFUSE, + AcFanSpeed.LOW: FAN_LOW, + AcFanSpeed.MEDIUM: FAN_MEDIUM, + AcFanSpeed.HIGH: FAN_HIGH, + AcFanSpeed.POWERFUL: FAN_FOCUS, + AcFanSpeed.TURBO: FAN_TURBO, + AcFanSpeed.INTELLIGENT_AUTO_1: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_2: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_3: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_4: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_5: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_6: FAN_INTELLIGENT_AUTO, +} +FAN_MODE_TO_SET_AC_FAN_SPEED = { + FAN_AUTO: SetAcFanSpeed.SET_TO_AUTO, + FAN_DIFFUSE: SetAcFanSpeed.SET_TO_QUIET, + FAN_LOW: SetAcFanSpeed.SET_TO_LOW, + FAN_MEDIUM: SetAcFanSpeed.SET_TO_MEDIUM, + FAN_HIGH: SetAcFanSpeed.SET_TO_HIGH, + FAN_FOCUS: SetAcFanSpeed.SET_TO_POWERFUL, + FAN_TURBO: SetAcFanSpeed.SET_TO_TURBO, + FAN_INTELLIGENT_AUTO: SetAcFanSpeed.SET_TO_INTELLIGENT_AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airtouch 5 Climate entities.""" + client: Airtouch5SimpleClient = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ClimateEntity] = [] + + # Add each AC (and remember what zones they apply to). + # Each zone is controlled by a single AC + zone_to_ac: dict[int, AcAbility] = {} + for ac in client.ac: + for i in range(ac.start_zone_number, ac.start_zone_number + ac.zone_count): + zone_to_ac[i] = ac + entities.append(Airtouch5AC(client, ac)) + + # Add each zone + for zone in client.zones: + entities.append(Airtouch5Zone(client, zone, zone_to_ac[zone.zone_number])) + + async_add_entities(entities) + + +class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): + """Base class for Airtouch5 Climate Entities.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1 + _attr_name = None + _enable_turn_on_off_backwards_compatibility = False + + +class Airtouch5AC(Airtouch5ClimateEntity): + """Representation of the AC unit. Used to control the overall HVAC Mode.""" + + def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None: + """Initialise the Climate Entity.""" + super().__init__(client) + self._ability = ability + self._attr_unique_id = f"ac_{ability.ac_number}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"ac_{ability.ac_number}")}, + name=f"AC {ability.ac_number}", + manufacturer="Polyaire", + model="AirTouch 5", + ) + self._attr_hvac_modes = [HVACMode.OFF] + if ability.supports_mode_auto: + self._attr_hvac_modes.append(HVACMode.AUTO) + if ability.supports_mode_cool: + self._attr_hvac_modes.append(HVACMode.COOL) + if ability.supports_mode_dry: + self._attr_hvac_modes.append(HVACMode.DRY) + if ability.supports_mode_fan: + self._attr_hvac_modes.append(HVACMode.FAN_ONLY) + if ability.supports_mode_heat: + self._attr_hvac_modes.append(HVACMode.HEAT) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + + self._attr_fan_modes = [] + if ability.supports_fan_speed_quiet: + self._attr_fan_modes.append(FAN_DIFFUSE) + if ability.supports_fan_speed_low: + self._attr_fan_modes.append(FAN_LOW) + if ability.supports_fan_speed_medium: + self._attr_fan_modes.append(FAN_MEDIUM) + if ability.supports_fan_speed_high: + self._attr_fan_modes.append(FAN_HIGH) + if ability.supports_fan_speed_powerful: + self._attr_fan_modes.append(FAN_FOCUS) + if ability.supports_fan_speed_turbo: + self._attr_fan_modes.append(FAN_TURBO) + if ability.supports_fan_speed_auto: + self._attr_fan_modes.append(FAN_AUTO) + if ability.supports_fan_speed_intelligent_auto: + self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO) + + # We can have different setpoints for heat cool, we expose the lowest low and highest high + self._attr_min_temp = min( + ability.min_cool_set_point, ability.min_heat_set_point + ) + self._attr_max_temp = max( + ability.max_cool_set_point, ability.max_heat_set_point + ) + + @callback + def _async_update_attrs(self, data: dict[int, AcStatus]) -> None: + if self._ability.ac_number not in data: + return + status = data[self._ability.ac_number] + + self._attr_current_temperature = status.temperature + self._attr_target_temperature = status.ac_setpoint + if status.ac_power_state in [AcPowerState.OFF, AcPowerState.AWAY_OFF]: + self._attr_hvac_mode = HVACMode.OFF + else: + self._attr_hvac_mode = AC_MODE_TO_HVAC_MODE[status.ac_mode] + self._attr_fan_mode = AC_FAN_SPEED_TO_FAN_SPEED[status.ac_fan_speed] + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + await super().async_added_to_hass() + self._client.ac_status_callbacks.append(self._async_update_attrs) + self._async_update_attrs(self._client.latest_ac_status) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + await super().async_will_remove_from_hass() + self._client.ac_status_callbacks.remove(self._async_update_attrs) + + async def _control( + self, + *, + power: SetPowerSetting = SetPowerSetting.KEEP_POWER_SETTING, + ac_mode: SetAcMode = SetAcMode.KEEP_AC_MODE, + fan: SetAcFanSpeed = SetAcFanSpeed.KEEP_AC_FAN_SPEED, + setpoint: SetpointControl = SetpointControl.KEEP_SETPOINT_VALUE, + temp: int = 0, + ) -> None: + control = AcControl( + power, + self._ability.ac_number, + ac_mode, + fan, + setpoint, + temp, + ) + packet = self._client.data_packet_factory.ac_control([control]) + await self._client.send_packet(packet) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new operation mode.""" + set_power_setting: SetPowerSetting + set_ac_mode: SetAcMode + + if hvac_mode == HVACMode.OFF: + set_power_setting = SetPowerSetting.SET_TO_OFF + set_ac_mode = SetAcMode.KEEP_AC_MODE + else: + set_power_setting = SetPowerSetting.SET_TO_ON + if hvac_mode not in HVAC_MODE_TO_SET_AC_MODE: + raise ValueError(f"Unsupported hvac mode: {hvac_mode}") + set_ac_mode = HVAC_MODE_TO_SET_AC_MODE[hvac_mode] + + await self._control(power=set_power_setting, ac_mode=set_ac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new fan mode.""" + if fan_mode not in FAN_MODE_TO_SET_AC_FAN_SPEED: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + fan_speed = FAN_MODE_TO_SET_AC_FAN_SPEED[fan_mode] + await self._control(fan=fan_speed) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + _LOGGER.debug("Argument `temperature` is missing in set_temperature") + return + + await self._control(temp=temp) + + +class Airtouch5Zone(Airtouch5ClimateEntity): + """Representation of a Zone. Used to control the AC effect in the zone.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] + _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + + def __init__( + self, client: Airtouch5SimpleClient, name: ZoneName, ac: AcAbility + ) -> None: + """Initialise the Climate Entity.""" + super().__init__(client) + self._name = name + + self._attr_unique_id = f"zone_{name.zone_number}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"zone_{name.zone_number}")}, + name=name.zone_name, + manufacturer="Polyaire", + model="AirTouch 5", + ) + # We can have different setpoints for heat and cool, we expose the lowest low and highest high + self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point) + self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point) + + @callback + def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None: + if self._name.zone_number not in data: + return + status = data[self._name.zone_number] + self._attr_current_temperature = status.temperature + self._attr_target_temperature = status.set_point + + if status.zone_power_state == ZonePowerState.OFF: + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = PRESET_NONE + elif status.zone_power_state == ZonePowerState.ON: + self._attr_hvac_mode = HVACMode.FAN_ONLY + self._attr_preset_mode = PRESET_NONE + elif status.zone_power_state == ZonePowerState.TURBO: + self._attr_hvac_mode = HVACMode.FAN_ONLY + self._attr_preset_mode = PRESET_BOOST + else: + self._attr_hvac_mode = None + + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + await super().async_added_to_hass() + self._client.zone_status_callbacks.append(self._async_update_attrs) + self._async_update_attrs(self._client.latest_zone_status) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + await super().async_will_remove_from_hass() + self._client.zone_status_callbacks.remove(self._async_update_attrs) + + async def _control( + self, + *, + zsv: ZoneSettingValue = ZoneSettingValue.KEEP_SETTING_VALUE, + power: ZoneSettingPower = ZoneSettingPower.KEEP_POWER_STATE, + value: float = 0, + ) -> None: + control = ZoneControlZone(self._name.zone_number, zsv, power, value) + packet = self._client.data_packet_factory.zone_control([control]) + await self._client.send_packet(packet) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new operation mode.""" + power: ZoneSettingPower + + if hvac_mode is HVACMode.OFF: + power = ZoneSettingPower.SET_TO_OFF + elif self._attr_preset_mode is PRESET_BOOST: + power = ZoneSettingPower.SET_TO_TURBO + else: + power = ZoneSettingPower.SET_TO_ON + + await self._control(power=power) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Enable or disable Turbo. Done this way as we can't have a turbo HVACMode.""" + power: ZoneSettingPower + if preset_mode == PRESET_BOOST: + power = ZoneSettingPower.SET_TO_TURBO + else: + power = ZoneSettingPower.SET_TO_ON + + await self._control(power=power) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + _LOGGER.debug("Argument `temperature` is missing in set_temperature") + return + + await self._control( + zsv=ZoneSettingValue.SET_TARGET_SETPOINT, + value=float(temp), + ) + + async def async_turn_on(self) -> None: + """Turn the zone on.""" + await self.async_set_hvac_mode(HVACMode.FAN_ONLY) + + async def async_turn_off(self) -> None: + """Turn the zone off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py new file mode 100644 index 00000000000..e5df2844653 --- /dev/null +++ b/homeassistant/components/airtouch5/config_flow.py @@ -0,0 +1,46 @@ +"""Config flow for Airtouch 5 integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airtouch 5.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] | None = None + if user_input is not None: + client = Airtouch5SimpleClient(user_input[CONF_HOST]) + try: + await client.test_connection() + except Exception: # pylint: disable=broad-exception-caught + errors = {"base": "cannot_connect"} + else: + await self.async_set_unique_id(user_input[CONF_HOST]) + 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="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airtouch5/const.py b/homeassistant/components/airtouch5/const.py new file mode 100644 index 00000000000..e98db04aaa3 --- /dev/null +++ b/homeassistant/components/airtouch5/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airtouch 5 integration.""" + +DOMAIN = "airtouch5" + +FAN_TURBO = "turbo" +FAN_INTELLIGENT_AUTO = "intelligent_auto" diff --git a/homeassistant/components/airtouch5/entity.py b/homeassistant/components/airtouch5/entity.py new file mode 100644 index 00000000000..a6ac76b5187 --- /dev/null +++ b/homeassistant/components/airtouch5/entity.py @@ -0,0 +1,40 @@ +"""Base class for Airtouch5 entities.""" +from airtouch5py.airtouch5_client import Airtouch5ConnectionStateChange +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class Airtouch5Entity(Entity): + """Base class for Airtouch5 entities.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_translation_key = DOMAIN + + def __init__(self, client: Airtouch5SimpleClient) -> None: + """Initialise the Entity.""" + self._client = client + self._attr_available = True + + @callback + def _receive_connection_callback( + self, state: Airtouch5ConnectionStateChange + ) -> None: + self._attr_available = state is Airtouch5ConnectionStateChange.CONNECTED + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + self._client.connection_state_callbacks.append( + self._receive_connection_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener when entity is removed from homeassistant.""" + self._client.connection_state_callbacks.remove( + self._receive_connection_callback + ) diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json new file mode 100644 index 00000000000..0d4cbc32761 --- /dev/null +++ b/homeassistant/components/airtouch5/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airtouch5", + "name": "AirTouch 5", + "codeowners": ["@danzel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch5", + "iot_class": "local_push", + "loggers": ["airtouch5py"], + "requirements": ["airtouch5py==0.2.8"] +} diff --git a/homeassistant/components/airtouch5/strings.json b/homeassistant/components/airtouch5/strings.json new file mode 100644 index 00000000000..6a91fa85fa5 --- /dev/null +++ b/homeassistant/components/airtouch5/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "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%]" + } + }, + "entity": { + "climate": { + "airtouch5": { + "state_attributes": { + "fan_mode": { + "state": { + "turbo": "Turbo", + "intelligent_auto": "Intelligent Auto" + } + } + } + } + } + } +} diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 6a8e32bc32c..2708cc5857d 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -26,22 +26,15 @@ from . import AirVisualProData, AirVisualProEntity from .const import DOMAIN -@dataclass(frozen=True) -class AirVisualProMeasurementKeyMixin: - """Define an entity description mixin to include a measurement key.""" +@dataclass(frozen=True, kw_only=True) +class AirVisualProMeasurementDescription(SensorEntityDescription): + """Describe an AirVisual Pro sensor.""" value_fn: Callable[ [dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]], float | int ] -@dataclass(frozen=True) -class AirVisualProMeasurementDescription( - SensorEntityDescription, AirVisualProMeasurementKeyMixin -): - """Describe an AirVisual Pro sensor.""" - - SENSOR_DESCRIPTIONS = ( AirVisualProMeasurementDescription( key="air_quality_index", @@ -67,6 +60,7 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: status["battery"], ), AirVisualProMeasurementDescription( @@ -80,6 +74,7 @@ SENSOR_DESCRIPTIONS = ( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: measurements[ "humidity" ], diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index f5a0e1b109e..2b4cae18086 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -117,6 +117,7 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -129,7 +130,11 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): super().__init__(coordinator, entry, system_zone_id, zone_data) self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._attr_target_temperature_step = API_TEMPERATURE_STEP self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ self.get_airzone_value(AZD_TEMP_UNIT) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index e076edc1f5b..1bab9dd6c33 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -144,8 +144,8 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @callback def _handle_coordinator_update(self) -> None: @@ -175,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): class AirzoneDeviceClimate(AirzoneClimate): """Define an Airzone Cloud Device base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -212,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate): class AirzoneDeviceGroupClimate(AirzoneClimate): """Define an Airzone Cloud DeviceGroup base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ab8e08835a3..f8b740dc04d 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/alarm_control_panel/icons.json b/homeassistant/components/alarm_control_panel/icons.json new file mode 100644 index 00000000000..62a9eee2915 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/icons.json @@ -0,0 +1,26 @@ +{ + "entity_component": { + "_": { + "default": "mdi:shield", + "state": { + "armed_away": "mdi:shield-lock", + "armed_custom_bypass": "mdi:security", + "armed_home": "mdi:shield-home", + "armed_night": "mdi:shield-moon", + "armed_vacation": "mdi:shield-airplane", + "disarmed": "mdi:shield-off", + "pending": "mdi:shield-outline", + "triggered": "mdi:bell-ring" + } + } + }, + "services": { + "alarm_arm_away": "mdi:shield-lock", + "alarm_arm_home": "mdi:shield-home", + "alarm_arm_night": "mdi:shield-moon", + "alarm_custom_bypass": "mdi:security", + "alarm_disarm": "mdi:shield-off", + "alarm_trigger": "mdi:bell-ring", + "arlam_arm_vacation": "mdi:shield-airplane" + } +} diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 807d12383bc..19d1d729a5e 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -39,8 +39,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, - Platform.SENSOR, Platform.BINARY_SENSOR, + Platform.SENSOR, ] diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 70679f8dafb..ddc0bc70987 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -380,12 +380,17 @@ def async_get_entities( if state.domain not in ENTITY_ADAPTERS: continue - alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) - - if not list(alexa_entity.interfaces()): - continue - - entities.append(alexa_entity) + try: + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + interfaces = list(alexa_entity.interfaces()) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Unable to serialize %s for discovery: %s", state.entity_id, exc + ) + else: + if not interfaces: + continue + entities.append(alexa_entity) return entities @@ -406,13 +411,11 @@ class GenericCapabilities(AlexaEntity): return [DisplayCategory.OTHER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaPowerController(self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaPowerController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(input_boolean.DOMAIN) @@ -431,14 +434,12 @@ class SwitchCapabilities(AlexaEntity): return [DisplayCategory.SWITCH] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaPowerController(self.entity), - AlexaContactSensor(self.hass, self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaPowerController(self.entity) + yield AlexaContactSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(button.DOMAIN) @@ -450,14 +451,12 @@ class ButtonCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=False), - AlexaEventDetectionSensor(self.hass, self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(climate.DOMAIN) @@ -479,6 +478,14 @@ class ClimateCapabilities(AlexaEntity): self.entity.domain == climate.DOMAIN and climate.HVACMode.OFF in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + or self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) + ) or self.entity.domain == water_heater.DOMAIN and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): @@ -676,13 +683,11 @@ class LockCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SMARTLOCK] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaLockController(self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaLockController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(media_player.const.DOMAIN) @@ -767,12 +772,10 @@ class SceneCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SCENE_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=False), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(script.DOMAIN) @@ -783,12 +786,10 @@ class ScriptCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=True), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=True) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(sensor.DOMAIN) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 463693f7da6..b5b72bc6dc5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -119,11 +119,18 @@ async def async_api_discovery( Async friendly. """ - discovery_endpoints = [ - alexa_entity.serialize_discovery() - for alexa_entity in async_get_entities(hass, config) - if config.should_expose(alexa_entity.entity_id) - ] + discovery_endpoints: list[dict[str, Any]] = [] + for alexa_entity in async_get_entities(hass, config): + if not config.should_expose(alexa_entity.entity_id): + continue + try: + discovered_serialized_entity = alexa_entity.serialize_discovery() + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Unable to serialize %s for discovery: %s", alexa_entity.entity_id, exc + ) + else: + discovery_endpoints.append(discovered_serialized_entity) return directive.response( name="Discover.Response", @@ -171,6 +178,8 @@ async def async_api_turn_on( service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_ON elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_ON elif domain == humidifier.DOMAIN: @@ -220,6 +229,8 @@ async def async_api_turn_off( service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_OFF elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_OFF elif domain == humidifier.DOMAIN: diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 02c6958e0da..52427065f68 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/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index 57971899cc0..55137b58832 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/amazon_polly", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.28.17"] + "requirements": ["boto3==1.33.13"] } diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index 1931bcbd32c..25a6c2fe267 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 @@ class AmberPriceGridSensor( @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 @@ class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): @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 0258fdf4cb4..765e219b6d7 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -3,18 +3,46 @@ from __future__ import annotations import amberelectric from amberelectric.api import amber_api -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_TOKEN from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN +from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN API_URL = "https://app.amber.com.au/developers" +def generate_site_selector_name(site: Site) -> str: + """Generate the name to show in the site drop down in the configuration flow.""" + if site.status == SiteStatus.CLOSED: + return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return] + if site.status == SiteStatus.PENDING: + return site.nmi + " (Pending)" # type: ignore[no-any-return] + return site.nmi # type: ignore[no-any-return] + + +def filter_sites(sites: list[Site]) -> list[Site]: + """Deduplicates the list of sites.""" + filtered: list[Site] = [] + filtered_nmi: set[str] = set() + + for site in sorted(sites, key=lambda site: site.status.value): + if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi: + filtered.append(site) + filtered_nmi.add(site.nmi) + + return filtered + + class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -28,10 +56,10 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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] = filter_sites(api.get_sites()) if len(sites) == 0: self._errors[CONF_API_TOKEN] = "no_site" return None @@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._sites is not None assert self._api_token is not None - api_token = self._api_token if user_input is not None: - site_nmi = user_input[CONF_SITE_NMI] - sites = [site for site in self._sites if site.nmi == site_nmi] - site = sites[0] - site_id = site.id + site_id = user_input[CONF_SITE_ID] name = user_input.get(CONF_SITE_NAME, site_id) return self.async_create_entry( title=name, - data={ - CONF_SITE_ID: site_id, - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: site.nmi, - }, + data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token}, ) - user_input = { - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: "", - CONF_SITE_NAME: "", - } - return self.async_show_form( step_id="site", data_schema=vol.Schema( { - vol.Required( - CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] - ): vol.In([site.nmi for site in self._sites]), - vol.Optional( - CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] - ): str, + vol.Required(CONF_SITE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=site.id, + label=generate_site_selector_name(site), + ) + for site in self._sites + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_SITE_NAME): str, } ), errors=self._errors, diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 5f92e5a9117..6166b21c19f 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -6,9 +6,8 @@ from homeassistant.const import Platform DOMAIN = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -CONF_SITE_NMI = "site_nmi" ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 75cf3fd4360..3e420be2f68 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/manifest.json b/homeassistant/components/amberelectric/manifest.json index 29de18d96de..13a9f257adb 100644 --- a/homeassistant/components/amberelectric/manifest.json +++ b/homeassistant/components/amberelectric/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amberelectric", "iot_class": "cloud_polling", "loggers": ["amberelectric"], - "requirements": ["amberelectric==1.0.4"] + "requirements": ["amberelectric==1.1.0"] } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 4a6d1a6ea18..97ecc103661 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 @@ class AmberPriceSensor(AmberSensor): 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 @@ class AmberForecastSensor(AmberSensor): 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 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): @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 2762c3948a7..58b2334260e 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -6,6 +6,7 @@ import logging from typing import Any import ambiclimate +from ambiclimate import AmbiclimateDevice import voluptuous as vol from homeassistant.components.climate import ( @@ -152,18 +153,23 @@ class AmbiclimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False - 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 3d05ab2bb07..383a11055e4 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -114,7 +114,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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) -> None: self.hass.http.register_view(AmbiclimateAuthCallbackView()) @@ -132,12 +132,12 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 8bdfe0fd642..25c95b2e20e 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -63,20 +63,13 @@ TYPE_RELAY8 = "relay8" TYPE_RELAY9 = "relay9" -@dataclass(frozen=True) -class AmbientBinarySensorDescriptionMixin: - """Define an entity description mixin for binary sensors.""" +@dataclass(frozen=True, kw_only=True) +class AmbientBinarySensorDescription(BinarySensorEntityDescription): + """Describe an Ambient PWS binary sensor.""" on_state: Literal[0, 1] -@dataclass(frozen=True) -class AmbientBinarySensorDescription( - BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin -): - """Describe an Ambient PWS binary sensor.""" - - BINARY_SENSOR_DESCRIPTIONS = ( AmbientBinarySensorDescription( key=TYPE_BATTOUT, diff --git a/homeassistant/components/ambient_station/icons.json b/homeassistant/components/ambient_station/icons.json new file mode 100644 index 00000000000..c5103bfd12e --- /dev/null +++ b/homeassistant/components/ambient_station/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "last_rain": { + "default": "mdi:water" + }, + "lightning_strikes_per_day": { + "default": "mdi:lightning-bolt" + }, + "lightning_strikes_per_hour": { + "default": "mdi:lightning-bolt" + }, + "wind_direction": { + "default": "mdi:weather-windy" + }, + "wind_direction_average_10m": { + "default": "mdi:weather-windy" + }, + "wind_direction_average_2m": { + "default": "mdi:weather-windy" + }, + "wind_gust_direction": { + "default": "mdi:weather-windy" + } + } + } +} diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index ebd03651064..046ab9f73e9 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioambient"], - "requirements": ["aioambient==2023.04.0"] + "requirements": ["aioambient==2024.01.0"] } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 4873da566b5..951bfc5c8ff 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -281,20 +281,17 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_LASTRAIN, translation_key="last_rain", - icon="mdi:water", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_DAY, translation_key="lightning_strikes_per_day", - icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_HOUR, translation_key="lightning_strikes_per_hour", - icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", state_class=SensorStateClass.TOTAL, ), @@ -595,25 +592,21 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_WINDDIR, translation_key="wind_direction", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, translation_key="wind_direction_average_10m", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG2M, translation_key="wind_direction_average_2m", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, translation_key="wind_gust_direction", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py new file mode 100644 index 00000000000..23965a9fcb5 --- /dev/null +++ b/homeassistant/components/analytics_insights/__init__.py @@ -0,0 +1,71 @@ +"""The Homeassistant Analytics integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN +from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass(frozen=True) +class AnalyticsInsightsData: + """Analytics data class.""" + + coordinator: HomeassistantAnalyticsDataUpdateCoordinator + names: dict[str, str] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Homeassistant Analytics from a config entry.""" + client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) + + try: + integrations = await client.get_integrations() + except HomeassistantAnalyticsConnectionError as ex: + raise ConfigEntryNotReady("Could not fetch integration list") from ex + + names = {} + for integration in entry.options[CONF_TRACKED_INTEGRATIONS]: + if integration not in integrations: + names[integration] = integration + continue + names[integration] = integrations[integration].title + + coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsInsightsData( + coordinator=coordinator, names=names + ) + + 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 a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py new file mode 100644 index 00000000000..d2ebdd943a2 --- /dev/null +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for Homeassistant Analytics integration.""" +from __future__ import annotations + +from typing import Any + +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, +) +from python_homeassistant_analytics.models import IntegrationType +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, + DOMAIN, + LOGGER, +) + +INTEGRATION_TYPES_WITHOUT_ANALYTICS = ( + IntegrationType.BRAND, + IntegrationType.ENTITY, + IntegrationType.VIRTUAL, +) + + +class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Homeassistant Analytics.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return HomeassistantAnalyticsOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + self._async_abort_entries_match() + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="Home Assistant Analytics Insights", + data={}, + options={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) + + client = HomeassistantAnalyticsClient( + session=async_get_clientsession(self.hass) + ) + try: + integrations = await client.get_integrations() + custom_integrations = await client.get_custom_integrations() + except HomeassistantAnalyticsConnectionError: + LOGGER.exception("Error connecting to Home Assistant analytics") + return self.async_abort(reason="cannot_connect") + + options = [ + SelectOptionDict( + value=domain, + label=integration.title, + ) + for domain, integration in integrations.items() + if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS + ] + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=True, + sort=True, + ) + ), + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=list(custom_integrations), + multiple=True, + sort=True, + ) + ), + } + ), + ) + + +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Homeassistant Analytics options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="", + data={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) + + client = HomeassistantAnalyticsClient( + session=async_get_clientsession(self.hass) + ) + try: + integrations = await client.get_integrations() + custom_integrations = await client.get_custom_integrations() + except HomeassistantAnalyticsConnectionError: + LOGGER.exception("Error connecting to Home Assistant analytics") + return self.async_abort(reason="cannot_connect") + + options = [ + SelectOptionDict( + value=domain, + label=integration.title, + ) + for domain, integration in integrations.items() + if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS + ] + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=True, + sort=True, + ) + ), + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=list(custom_integrations), + multiple=True, + sort=True, + ) + ), + }, + ), + self.options, + ), + ) diff --git a/homeassistant/components/analytics_insights/const.py b/homeassistant/components/analytics_insights/const.py new file mode 100644 index 00000000000..745c05302a1 --- /dev/null +++ b/homeassistant/components/analytics_insights/const.py @@ -0,0 +1,9 @@ +"""Constants for the Homeassistant Analytics integration.""" +import logging + +DOMAIN = "analytics_insights" + +CONF_TRACKED_INTEGRATIONS = "tracked_integrations" +CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py new file mode 100644 index 00000000000..c646288cbe0 --- /dev/null +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -0,0 +1,84 @@ +"""DataUpdateCoordinator for the Homeassistant Analytics integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from python_homeassistant_analytics import ( + CustomIntegration, + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, + HomeassistantAnalyticsNotModifiedError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, + DOMAIN, + LOGGER, +) + + +@dataclass(frozen=True) +class AnalyticsData: + """Analytics data class.""" + + core_integrations: dict[str, int] + custom_integrations: dict[str, int] + + +class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[AnalyticsData]): + """A Homeassistant Analytics Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, client: HomeassistantAnalyticsClient + ) -> None: + """Initialize the Homeassistant Analytics data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=12), + ) + self._client = client + self._tracked_integrations = self.config_entry.options[ + CONF_TRACKED_INTEGRATIONS + ] + self._tracked_custom_integrations = self.config_entry.options[ + CONF_TRACKED_CUSTOM_INTEGRATIONS + ] + + async def _async_update_data(self) -> AnalyticsData: + try: + data = await self._client.get_current_analytics() + custom_data = await self._client.get_custom_integrations() + except HomeassistantAnalyticsConnectionError as err: + raise UpdateFailed( + "Error communicating with Homeassistant Analytics" + ) from err + except HomeassistantAnalyticsNotModifiedError: + return self.data + core_integrations = { + integration: data.integrations.get(integration, 0) + for integration in self._tracked_integrations + } + custom_integrations = { + integration: get_custom_integration_value(custom_data, integration) + for integration in self._tracked_custom_integrations + } + return AnalyticsData(core_integrations, custom_integrations) + + +def get_custom_integration_value( + data: dict[str, CustomIntegration], domain: str +) -> int: + """Get custom integration value.""" + if domain in data: + return data[domain].total + return 0 diff --git a/homeassistant/components/analytics_insights/icons.json b/homeassistant/components/analytics_insights/icons.json new file mode 100644 index 00000000000..705578dbc6b --- /dev/null +++ b/homeassistant/components/analytics_insights/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "core_integrations": { + "default": "mdi:puzzle" + }, + "custom_integrations": { + "default": "mdi:puzzle-edit" + } + } + } +} diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json new file mode 100644 index 00000000000..d33bb23b1b7 --- /dev/null +++ b/homeassistant/components/analytics_insights/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "analytics_insights", + "name": "Home Assistant Analytics Insights", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/analytics_insights", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["python_homeassistant_analytics"], + "requirements": ["python-homeassistant-analytics==0.6.0"] +} diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py new file mode 100644 index 00000000000..90e9ff51b87 --- /dev/null +++ b/homeassistant/components/analytics_insights/sensor.py @@ -0,0 +1,118 @@ +"""Sensor for Home Assistant analytics.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +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 AnalyticsInsightsData +from .const import DOMAIN +from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class AnalyticsSensorEntityDescription(SensorEntityDescription): + """Analytics sensor entity description.""" + + value_fn: Callable[[AnalyticsData], StateType] + + +def get_core_integration_entity_description( + domain: str, name: str +) -> AnalyticsSensorEntityDescription: + """Get core integration entity description.""" + return AnalyticsSensorEntityDescription( + key=f"core_{domain}_active_installations", + translation_key="core_integrations", + name=name, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="active installations", + value_fn=lambda data: data.core_integrations.get(domain), + ) + + +def get_custom_integration_entity_description( + domain: str, +) -> AnalyticsSensorEntityDescription: + """Get custom integration entity description.""" + return AnalyticsSensorEntityDescription( + key=f"custom_{domain}_active_installations", + translation_key="custom_integrations", + translation_placeholders={"custom_integration_domain": domain}, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="active installations", + value_fn=lambda data: data.custom_integrations.get(domain), + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + analytics_data: AnalyticsInsightsData = hass.data[DOMAIN][entry.entry_id] + coordinator: HomeassistantAnalyticsDataUpdateCoordinator = ( + analytics_data.coordinator + ) + entities: list[HomeassistantAnalyticsSensor] = [] + entities.extend( + HomeassistantAnalyticsSensor( + coordinator, + get_core_integration_entity_description( + integration_domain, analytics_data.names[integration_domain] + ), + ) + for integration_domain in coordinator.data.core_integrations + ) + entities.extend( + HomeassistantAnalyticsSensor( + coordinator, + get_custom_integration_entity_description(integration_domain), + ) + for integration_domain in coordinator.data.custom_integrations + ) + async_add_entities(entities) + + +class HomeassistantAnalyticsSensor( + CoordinatorEntity[HomeassistantAnalyticsDataUpdateCoordinator], SensorEntity +): + """Home Assistant Analytics Sensor.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + entity_description: AnalyticsSensorEntityDescription + + def __init__( + self, + coordinator: HomeassistantAnalyticsDataUpdateCoordinator, + entity_description: AnalyticsSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, DOMAIN)}, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json new file mode 100644 index 00000000000..6de1ab9dbe4 --- /dev/null +++ b/homeassistant/components/analytics_insights/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "data": { + "tracked_integrations": "Integrations", + "tracked_custom_integrations": "Custom integrations" + }, + "data_description": { + "tracked_integrations": "Select the integrations you want to track", + "tracked_custom_integrations": "Select the custom integrations you want to track" + } + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "no_integration_selected": "You must select at least one integration to track" + } + }, + "options": { + "step": { + "init": { + "data": { + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]" + }, + "data_description": { + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]" + } + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]" + } + }, + "entity": { + "sensor": { + "custom_integrations": { + "name": "{custom_integration_domain} (custom)" + } + } + } +} diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 4a1ad55e0b1..cd9e42aeb4d 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -28,7 +28,7 @@ from homeassistant.const import ( 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 7e2b1e85f39..e688b0a92de 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 496b4e51e4f..bd058ac769e 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -313,7 +313,7 @@ class ADBDevice(MediaPlayerEntity): @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 @@ class ADBDevice(MediaPlayerEntity): 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/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 19d1a2deaff..827fc0037a7 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -5,7 +5,7 @@ from datetime import timedelta 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 @@ class PwrCtrlSwitch(SwitchEntity): 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/icons.json b/homeassistant/components/anova/icons.json new file mode 100644 index 00000000000..9e0e88178d3 --- /dev/null +++ b/homeassistant/components/anova/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "cook_time": { + "default": "mdi:clock-outline" + }, + "target_temperature": { + "default": "mdi:thermometer" + }, + "cook_time_remaining": { + "default": "mdi:clock-outline" + }, + "heater_temperature": { + "default": "mdi:thermometer" + }, + "triac_temperature": { + "default": "mdi:thermometer" + }, + "water_temperature": { + "default": "mdi:thermometer" + } + } + } +} diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index b7657e26249..24bda4dbed6 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -42,30 +42,31 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:clock-outline", translation_key="cook_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time, ), AnovaSensorEntityDescription( - key="state", translation_key="state", value_fn=lambda data: data.state + key="state", + translation_key="state", + value_fn=lambda data: data.state, ), AnovaSensorEntityDescription( - key="mode", translation_key="mode", value_fn=lambda data: data.mode + key="mode", + translation_key="mode", + value_fn=lambda data: data.mode, ), AnovaSensorEntityDescription( key="target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="target_temperature", value_fn=lambda data: data.target_temperature, ), AnovaSensorEntityDescription( key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:clock-outline", translation_key="cook_time_remaining", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time_remaining, @@ -75,7 +76,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="heater_temperature", value_fn=lambda data: data.heater_temperature, ), @@ -84,7 +84,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="triac_temperature", value_fn=lambda data: data.triac_temperature, ), @@ -93,7 +92,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="water_temperature", value_fn=lambda data: data.water_temperature, ), diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index c13e6389bfc..0a9edeb2269 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -50,6 +50,7 @@ class AnthemAVR(MediaPlayerEntity): """Entity reading values from Anthem AVR protocol.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_icon = "mdi:audio-video" @@ -77,18 +78,23 @@ class AnthemAVR(MediaPlayerEntity): self._zone_number = zone_number self._zone = avr.zones[zone_number] if zone_number > 1: - self._attr_name = f"zone {zone_number}" - self._attr_unique_id = f"{mac_address}_{zone_number}" + unique_id = f"{mac_address}_{zone_number}" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Zone {zone_number}", + manufacturer=MANUFACTURER, + model=model, + via_device=(DOMAIN, mac_address), + ) else: - self._attr_name = None self._attr_unique_id = mac_address - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac_address)}, - name=name, - manufacturer=MANUFACTURER, - model=model, - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac_address)}, + name=name, + manufacturer=MANUFACTURER, + model=model, + ) self.set_states() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index b75a4ad7295..4da390685ab 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -37,16 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await status_coordinator.async_config_entry_first_refresh() device_registry = dr.async_get(hass) - for junction_id, status_data in status_coordinator.data.items(): + for junction_id, aosmith_device 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"), + name=aosmith_device.name, + model=aosmith_device.model, + serial_number=aosmith_device.serial, + suggested_area=aosmith_device.install_location, + sw_version=aosmith_device.status.firmware_version, ) energy_coordinator = AOSmithEnergyCoordinator( diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py index c0c693e0dac..ba9980293dc 100644 --- a/homeassistant/components/aosmith/const.py +++ b/homeassistant/components/aosmith/const.py @@ -4,11 +4,6 @@ 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) @@ -17,9 +12,3 @@ 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 index 7d6053cc86e..a0dd703b800 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,12 +1,12 @@ """The data update coordinator for the A. O. Smith integration.""" import logging -from typing import Any from py_aosmith import ( AOSmithAPIClient, AOSmithInvalidCredentialsException, AOSmithUnknownException, ) +from py_aosmith.models import Device as AOSmithDevice from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -17,7 +17,7 @@ from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVA _LOGGER = logging.getLogger(__name__) -class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): +class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): """Coordinator for device status, updating with a frequent interval.""" def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: @@ -25,7 +25,7 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) self.client = client - async def _async_update_data(self) -> dict[str, dict[str, Any]]: + async def _async_update_data(self) -> dict[str, AOSmithDevice]: """Fetch latest data from the device status endpoint.""" try: devices = await self.client.get_devices() @@ -34,12 +34,9 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) 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 - ) + mode_pending = any(device.status.mode_change_pending for device in devices) setpoint_pending = any( - device.get("data", {}).get("temperatureSetpointPending") - for device in devices + device.status.temperature_setpoint_pending for device in devices ) if mode_pending or setpoint_pending: @@ -47,7 +44,7 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) else: self.update_interval = REGULAR_INTERVAL - return {device.get("junctionId"): device for device in devices} + return {device.junction_id: device for device in devices} class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): @@ -78,6 +75,6 @@ class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): 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") + energy_usage_by_junction_id[junction_id] = energy_usage.lifetime_kwh return energy_usage_by_junction_id diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py index 107e5d7e944..7407fbac3cb 100644 --- a/homeassistant/components/aosmith/entity.py +++ b/homeassistant/components/aosmith/entity.py @@ -2,6 +2,7 @@ from typing import TypeVar from py_aosmith import AOSmithAPIClient +from py_aosmith.models import Device as AOSmithDevice from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,26 +38,20 @@ 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", {}) + def device(self) -> AOSmithDevice: + """Shortcut to get the device from the coordinator data.""" + return self.coordinator.data[self.junction_id] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.device_data.get("isOnline") is True + return super().available and self.device.status.is_online class AOSmithEnergyEntity(AOSmithEntity[AOSmithEnergyCoordinator]): """Base entity for entities that use data from the energy coordinator.""" @property - def energy_usage(self) -> float | None: + def energy_usage(self) -> float: """Shortcut to get the energy usage from the coordinator data.""" - return self.coordinator.data.get(self.junction_id) + return self.coordinator.data[self.junction_id] diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 7651086e138..436918ae772 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.4"] + "requirements": ["py-aosmith==1.0.6"] } diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index b0606d2dca4..e4a99a340de 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -2,7 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any + +from py_aosmith.models import Device as AOSmithDevice, HotWaterStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AOSmithData -from .const import DOMAIN, HOT_WATER_STATUS_MAP +from .const import DOMAIN from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator from .entity import AOSmithEnergyEntity, AOSmithStatusEntity @@ -25,7 +26,7 @@ from .entity import AOSmithEnergyEntity, AOSmithStatusEntity class AOSmithStatusSensorEntityDescription(SensorEntityDescription): """Entity description class for sensors using data from the status coordinator.""" - value_fn: Callable[[dict[str, Any]], str | int | None] + value_fn: Callable[[AOSmithDevice], str | int | None] STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( @@ -36,11 +37,17 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=["low", "medium", "high"], value_fn=lambda device: HOT_WATER_STATUS_MAP.get( - device.get("data", {}).get("hotWaterStatus") + device.status.hot_water_status ), ), ) +HOT_WATER_STATUS_MAP: dict[HotWaterStatus, str] = { + HotWaterStatus.LOW: "low", + HotWaterStatus.MEDIUM: "medium", + HotWaterStatus.HIGH: "high", +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index 8c42048d439..dceba13ba34 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -2,6 +2,8 @@ from typing import Any +from py_aosmith.models import OperationMode as AOSmithOperationMode + from homeassistant.components.water_heater import ( STATE_ECO, STATE_ELECTRIC, @@ -13,39 +15,34 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError 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 .const import 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, + STATE_ECO: AOSmithOperationMode.HYBRID, + STATE_ELECTRIC: AOSmithOperationMode.ELECTRIC, + STATE_HEAT_PUMP: AOSmithOperationMode.HEAT_PUMP, + STATE_OFF: AOSmithOperationMode.VACATION, } 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, + AOSmithOperationMode.ELECTRIC: STATE_ELECTRIC, + AOSmithOperationMode.HEAT_PUMP: STATE_HEAT_PUMP, + AOSmithOperationMode.HYBRID: STATE_ECO, + AOSmithOperationMode.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 -) +# Priority list for operation mode to use when exiting away mode +# Will use the first mode that is supported by the device +DEFAULT_OPERATION_MODE_PRIORITY = [ + AOSmithOperationMode.HYBRID, + AOSmithOperationMode.HEAT_PUMP, + AOSmithOperationMode.ELECTRIC, +] async def async_setup_entry( @@ -79,73 +76,85 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): @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) + ha_modes = [] + for supported_mode in self.device.supported_modes: + ha_mode = MODE_AOSMITH_TO_HA.get(supported_mode.mode) # 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) + ha_modes.append(ha_mode) - return op_modes + return ha_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", []) + supported_mode.mode == AOSmithOperationMode.VACATION + for supported_mode in self.device.supported_modes ) - if supports_vacation_mode: - return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + support_flags = WaterHeaterEntityFeature.TARGET_TEMPERATURE - return DEFAULT_SUPPORT_FLAGS + # Operation mode only supported if there is more than one mode + if len(self.operation_list) > 1: + support_flags |= WaterHeaterEntityFeature.OPERATION_MODE + + if supports_vacation_mode: + support_flags |= WaterHeaterEntityFeature.AWAY_MODE + + return support_flags @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.device_data.get("temperatureSetpoint") + return self.device.status.temperature_setpoint @property def max_temp(self) -> float: """Return the maximum temperature.""" - return self.device_data.get("temperatureSetpointMaximum") + return self.device.status.temperature_setpoint_maximum @property def current_operation(self) -> str: """Return the current operation mode.""" - return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + return MODE_AOSMITH_TO_HA.get(self.device.status.current_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 + return self.device.status.current_mode == AOSmithOperationMode.VACATION async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" + if operation_mode not in self.operation_list: + raise HomeAssistantError("Operation mode not supported") + 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() + 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) + if temperature is not None: + await self.client.update_setpoint(self.junction_id, temperature) - await self.coordinator.async_request_refresh() + 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.client.update_mode(self.junction_id, AOSmithOperationMode.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) + supported_aosmith_modes = [x.mode for x in self.device.supported_modes] - await self.coordinator.async_request_refresh() + for mode in DEFAULT_OPERATION_MODE_PRIORITY: + if mode in supported_aosmith_modes: + await self.client.update_mode(self.junction_id, mode) + break diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index c974735791e..c49d2954424 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,8 +1,11 @@ """Support for Apache Kafka.""" +from __future__ import annotations + from datetime import datetime import json -import sys +from typing import Any, Literal +from aiokafka import AIOKafkaProducer import voluptuous as vol from homeassistant.const import ( @@ -15,17 +18,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Event, HomeAssistant 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): - from aiokafka import AIOKafkaProducer - - DOMAIN = "apache_kafka" CONF_FILTER = "filter" @@ -54,10 +53,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate the Apache Kafka integration.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Apache Kafka is not supported on Python 3.12. Please use Python 3.11." - ) conf = config[DOMAIN] kafka = hass.data[DOMAIN] = KafkaManager( @@ -84,11 +79,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 +91,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 +116,30 @@ class KafkaManager: ) 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/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json index 11cb0ece7ac..f6593631bc0 100644 --- a/homeassistant/components/apache_kafka/manifest.json +++ b/homeassistant/components/apache_kafka/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apache_kafka", "iot_class": "local_push", "loggers": ["aiokafka", "kafka_python"], - "requirements": ["aiokafka==0.7.2"] + "requirements": ["aiokafka==0.10.0"] } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 057e85613fd..d012dfc372f 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,7 +1,6 @@ """Rest API for Home Assistant.""" import asyncio from asyncio import shield, timeout -from collections.abc import Collection from functools import lru_cache from http import HTTPStatus import logging @@ -14,7 +13,12 @@ import voluptuous as vol from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING -from homeassistant.components.http import HomeAssistantView, require_admin +from homeassistant.components.http import ( + KEY_HASS, + KEY_HASS_USER, + HomeAssistantView, + require_admin, +) from homeassistant.const import ( CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, @@ -32,7 +36,7 @@ from homeassistant.const import ( URL_API_TEMPLATE, ) import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ( InvalidEntityFormatError, InvalidStateError, @@ -42,11 +46,10 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import EventStateChangedData -from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.json import json_dumps, json_fragment from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.json import json_loads -from homeassistant.util.read_only_dict import ReadOnlyDict _LOGGER = logging.getLogger(__name__) @@ -95,7 +98,7 @@ class APIStatusView(HomeAssistantView): name = "api:status" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" return self.json_message("API running.") @@ -114,7 +117,7 @@ class APICoreStateView(HomeAssistantView): Home Assistant core is running. Its primary use case is for supervisor to check if Home Assistant is running. """ - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return self.json({"state": hass.state.value}) @@ -125,16 +128,17 @@ class APIEventStream(HomeAssistantView): name = "api:stream" @require_admin - async def get(self, request): + async def get(self, request: web.Request) -> web.StreamResponse: """Provide a streaming interface for the event bus.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] stop_obj = object() - to_write = asyncio.Queue() + to_write: asyncio.Queue[object | str] = asyncio.Queue() - if restrict := request.query.get("restrict"): - restrict = restrict.split(",") + [EVENT_HOMEASSISTANT_STOP] + restrict: list[str] | None = None + if restrict_str := request.query.get("restrict"): + restrict = restrict_str.split(",") + [EVENT_HOMEASSISTANT_STOP] - async def forward_events(event): + async def forward_events(event: Event) -> None: """Forward events to the open request.""" if restrict and event.event_type not in restrict: return @@ -191,9 +195,10 @@ class APIConfigView(HomeAssistantView): name = "api:config" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current configuration.""" - return self.json(request.app["hass"].config.as_dict()) + hass: HomeAssistant = request.app[KEY_HASS] + return self.json(hass.config.as_dict()) class APIStatesView(HomeAssistantView): @@ -205,8 +210,8 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request: web.Request) -> web.Response: """Get current states.""" - user: User = request["hass_user"] - hass: HomeAssistant = request.app["hass"] + user: User = request[KEY_HASS_USER] + hass: HomeAssistant = request.app[KEY_HASS] if user.is_admin: states = (state.as_dict_json for state in hass.states.async_all()) else: @@ -217,7 +222,7 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', + body=b"[" + b",".join(states) + b"]", content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) @@ -234,8 +239,8 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request: web.Request, entity_id: str) -> web.Response: """Retrieve state of entity.""" - user: User = request["hass_user"] - hass: HomeAssistant = request.app["hass"] + user: User = request[KEY_HASS_USER] + hass: HomeAssistant = request.app[KEY_HASS] if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) @@ -246,11 +251,12 @@ class APIEntityStateView(HomeAssistantView): ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) - async def post(self, request, entity_id): + async def post(self, request: web.Request, entity_id: str) -> web.Response: """Update state of entity.""" - if not request["hass_user"].is_admin: + user: User = request[KEY_HASS_USER] + if not user.is_admin: raise Unauthorized(entity_id=entity_id) - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] try: data = await request.json() except ValueError: @@ -278,18 +284,20 @@ class APIEntityStateView(HomeAssistantView): # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - resp = self.json(hass.states.get(entity_id).as_dict(), status_code) + assert (state := hass.states.get(entity_id)) + resp = self.json(state.as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") return resp @ha.callback - def delete(self, request, entity_id): + def delete(self, request: web.Request, entity_id: str) -> web.Response: """Remove entity.""" - if not request["hass_user"].is_admin: + if not request[KEY_HASS_USER].is_admin: raise Unauthorized(entity_id=entity_id) - if request.app["hass"].states.async_remove(entity_id): + hass: HomeAssistant = request.app[KEY_HASS] + if hass.states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) @@ -301,9 +309,10 @@ class APIEventListenersView(HomeAssistantView): name = "api:event-listeners" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get event listeners.""" - return self.json(async_events_json(request.app["hass"])) + hass: HomeAssistant = request.app[KEY_HASS] + return self.json(async_events_json(hass)) class APIEventView(HomeAssistantView): @@ -313,11 +322,11 @@ class APIEventView(HomeAssistantView): name = "api:event" @require_admin - async def post(self, request, event_type): + async def post(self, request: web.Request, event_type: str) -> web.Response: """Fire events.""" body = await request.text() try: - event_data = json_loads(body) if body else None + event_data: Any = json_loads(body) if body else None except ValueError: return self.json_message( "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST @@ -330,14 +339,15 @@ class APIEventView(HomeAssistantView): # Special case handling for event STATE_CHANGED # We will try to convert state dicts back to State objects - if event_type == ha.EVENT_STATE_CHANGED and event_data: + if event_type == EVENT_STATE_CHANGED and event_data: for key in ("old_state", "new_state"): - state = ha.State.from_dict(event_data.get(key)) + state = ha.State.from_dict(event_data[key]) if state: event_data[key] = state - request.app["hass"].bus.async_fire( + hass: HomeAssistant = request.app[KEY_HASS] + hass.bus.async_fire( event_type, event_data, ha.EventOrigin.remote, self.context(request) ) @@ -350,9 +360,10 @@ class APIServicesView(HomeAssistantView): url = URL_API_SERVICES name = "api:services" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Get registered services.""" - services = await async_services_json(request.app["hass"]) + hass: HomeAssistant = request.app[KEY_HASS] + services = await async_services_json(hass) return self.json(services) @@ -362,12 +373,14 @@ class APIDomainServicesView(HomeAssistantView): url = "/api/services/{domain}/{service}" name = "api:domain-services" - async def post(self, request, domain, service): + async def post( + self, request: web.Request, domain: str, service: str + ) -> web.Response: """Call a service. Returns a list of changed states. """ - hass: ha.HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] body = await request.text() try: data = json_loads(body) if body else None @@ -377,24 +390,30 @@ class APIDomainServicesView(HomeAssistantView): ) context = self.context(request) - changed_states: list[ReadOnlyDict[str, Collection[Any]]] = [] + changed_states: list[json_fragment] = [] @ha.callback def _async_save_changed_entities( event: EventType[EventStateChangedData], ) -> None: if event.context == context and (state := event.data["new_state"]): - changed_states.append(state.as_dict()) + changed_states.append(state.json_fragment) cancel_listen = hass.bus.async_listen( - EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True + EVENT_STATE_CHANGED, + _async_save_changed_entities, # type: ignore[arg-type] + run_immediately=True, ) try: # shield the service call from cancellation on connection drop await shield( hass.services.async_call( - domain, service, data, blocking=True, context=context + domain, + service, + data, # type: ignore[arg-type] + blocking=True, + context=context, ) ) except (vol.Invalid, ServiceNotFound) as ex: @@ -412,13 +431,14 @@ class APIComponentsView(HomeAssistantView): name = "api:components" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current loaded components.""" - return self.json(request.app["hass"].config.components) + hass: HomeAssistant = request.app[KEY_HASS] + return self.json(list(hass.config.components)) @lru_cache -def _cached_template(template_str: str, hass: ha.HomeAssistant) -> template.Template: +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: """Return a cached template.""" return template.Template(template_str, hass) @@ -430,12 +450,12 @@ class APITemplateView(HomeAssistantView): name = "api:template" @require_admin - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Render a template.""" try: data = await request.json() - tpl = _cached_template(data["template"], request.app["hass"]) - return tpl.async_render(variables=data.get("variables"), parse_result=False) + tpl = _cached_template(data["template"], request.app[KEY_HASS]) + return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return] except (ValueError, TemplateError) as ex: return self.json_message( f"Error rendering template: {ex}", HTTPStatus.BAD_REQUEST @@ -449,19 +469,20 @@ class APIErrorLog(HomeAssistantView): name = "api:error_log" @require_admin - async def get(self, request): + async def get(self, request: web.Request) -> web.FileResponse: """Retrieve API error log.""" - return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) + hass: HomeAssistant = request.app[KEY_HASS] + return web.FileResponse(hass.data[DATA_LOGGING]) -async def async_services_json(hass): +async def async_services_json(hass: HomeAssistant) -> list[dict[str, Any]]: """Generate services data to JSONify.""" descriptions = await async_get_all_descriptions(hass) return [{"domain": key, "services": value} for key, value in descriptions.items()] @ha.callback -def async_events_json(hass): +def async_events_json(hass: HomeAssistant) -> list[dict[str, Any]]: """Generate event data to JSONify.""" return [ {"event": key, "listener_count": value} diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 818d27bcf77..8f52db13cfa 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -2,10 +2,13 @@ import asyncio import logging from random import randrange +from typing import TYPE_CHECKING, cast from pyatv import connect, exceptions, scan +from pyatv.conf import AppleTV from pyatv.const import DeviceModel, Protocol from pyatv.convert import model_str +from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry @@ -92,10 +95,14 @@ class AppleTVEntity(Entity): _attr_has_entity_name = True _attr_name = None - def __init__(self, name, identifier, manager): + def __init__( + self, name: str, identifier: str | None, manager: "AppleTVManager" + ) -> None: """Initialize device.""" - self.atv = None + self.atv: AppleTVInterface = None # type: ignore[assignment] self.manager = manager + if TYPE_CHECKING: + assert identifier is not None self._attr_unique_id = identifier self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, @@ -143,7 +150,7 @@ class AppleTVEntity(Entity): """Handle when connection was lost to device.""" -class AppleTVManager: +class AppleTVManager(DeviceListener): """Connection and power manager for an Apple TV. An instance is used per device to share the same power state between @@ -151,11 +158,11 @@ class AppleTVManager: in case of problems. """ - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize power manager.""" self.config_entry = config_entry self.hass = hass - self.atv = None + self.atv: AppleTVInterface | None = None self.is_on = not config_entry.options.get(CONF_START_OFF, False) self._connection_attempts = 0 self._connection_was_lost = False @@ -220,7 +227,7 @@ class AppleTVManager: "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) - async def connect_once(self, raise_missing_credentials): + async def connect_once(self, raise_missing_credentials: bool) -> None: """Try to connect once.""" try: if conf := await self._scan(): @@ -264,49 +271,51 @@ class AppleTVManager: _LOGGER.debug("Connect loop ended") self._task = None - async def _scan(self): + async def _scan(self) -> AppleTV | None: """Try to find device by scanning for it.""" - identifiers = set( - self.config_entry.data.get(CONF_IDENTIFIERS, [self.config_entry.unique_id]) + config_entry = self.config_entry + identifiers: set[str] = set( + config_entry.data.get(CONF_IDENTIFIERS, [config_entry.unique_id]) ) - address = self.config_entry.data[CONF_ADDRESS] + address: str = config_entry.data[CONF_ADDRESS] + hass = self.hass # Only scan for and set up protocols that was successfully paired protocols = { - Protocol(int(protocol)) - for protocol in self.config_entry.data[CONF_CREDENTIALS] + Protocol(int(protocol)) for protocol in config_entry.data[CONF_CREDENTIALS] } - _LOGGER.debug("Discovering device %s", self.config_entry.title) - aiozc = await zeroconf.async_get_async_instance(self.hass) + _LOGGER.debug("Discovering device %s", config_entry.title) + aiozc = await zeroconf.async_get_async_instance(hass) atvs = await scan( - self.hass.loop, + hass.loop, identifier=identifiers, protocol=protocols, hosts=[address], aiozc=aiozc, ) if atvs: - return atvs[0] + return cast(AppleTV, atvs[0]) _LOGGER.debug( "Failed to find device %s with address %s", - self.config_entry.title, + config_entry.title, address, ) # We no longer multicast scan for the device since as soon as async_step_zeroconf runs, # it will update the address and reload the config entry when the device is found. return None - async def _connect(self, conf, raise_missing_credentials): + async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None: """Connect to device.""" - credentials = self.config_entry.data[CONF_CREDENTIALS] - name = self.config_entry.data[CONF_NAME] + config_entry = self.config_entry + credentials: dict[int, str | None] = config_entry.data[CONF_CREDENTIALS] + name: str = config_entry.data[CONF_NAME] missing_protocols = [] for protocol_int, creds in credentials.items(): protocol = Protocol(int(protocol_int)) if conf.get_service(protocol) is not None: - conf.set_credentials(protocol, creds) + conf.set_credentials(protocol, creds) # type: ignore[arg-type] else: missing_protocols.append(protocol.name) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 6a85ea1d1a8..11d408ee2ca 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -126,8 +126,8 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @callback 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( + for entry in self._async_current_entries(include_ignore=True): + if not all_identifiers.isdisjoint( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ): return entry.unique_id @@ -186,7 +186,6 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_not_supported") host = discovery_info.host - self._async_abort_entries_match({CONF_ADDRESS: host}) service_type = discovery_info.type[:-1] # Remove leading . name = discovery_info.name.replace(f".{service_type}.", "") properties = discovery_info.properties @@ -196,6 +195,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if unique_id is None: return self.async_abort(reason="unknown") + # The unique id for the zeroconf service may not be + # the same as the unique id for the device. If the + # device is already configured so if we don't + # find a match here, we will fallback to + # looking up the device by all its identifiers + # in the next block. + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host}) + if existing_unique_id := self._entry_unique_id_from_identifers({unique_id}): await self.async_set_unique_id(existing_unique_id) self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host}) @@ -326,7 +334,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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( @@ -538,13 +546,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # If an existing config entry is updated, then this was a re-auth if existing_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( existing_entry, data=data, unique_id=self.unique_id ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(existing_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.atv.name, data=data) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index dd1f554919e..789415a1717 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -154,9 +154,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): _LOGGER.exception("Failed to update app list") else: self._app_list = { - app.name: app.identifier - for app in sorted(apps, key=lambda app: app.name.lower()) - if app.name is not None + app_name: app.identifier + for app in sorted(apps, key=lambda app: (app.name or "").lower()) + if (app_name := app.name) is not None } self.async_write_ha_state() @@ -214,15 +214,19 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def app_id(self) -> str | None: """ID of the current running app.""" - if self._is_feature_available(FeatureName.App): - return self.atv.metadata.app.identifier + if self._is_feature_available(FeatureName.App) and ( + app := self.atv.metadata.app + ): + return app.identifier return None @property def app_name(self) -> str | None: """Name of the current running app.""" - if self._is_feature_available(FeatureName.App): - return self.atv.metadata.app.name + if self._is_feature_available(FeatureName.App) and ( + app := self.atv.metadata.app + ): + return app.name return None @property @@ -479,7 +483,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_media_seek(self, position: float) -> None: """Send seek command.""" if self.atv: - await self.atv.remote_control.set_position(position) + await self.atv.remote_control.set_position(round(position)) async def async_volume_up(self) -> None: """Turn volume up for media player.""" diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index bab3421c58d..24d2ef68ed4 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -81,5 +81,5 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() + await attr_value() # type: ignore[operator] await asyncio.sleep(delay) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 8132f3623a9..dd630ccc872 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.6.0"] + "requirements": ["apprise==1.7.2"] } diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index b1467a6d2e4..8b952f88c7c 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import threading +from typing import Any import aprslib from aprslib import ConnectionError as AprsConnectionError, LoginError @@ -23,7 +24,7 @@ from homeassistant.const import ( 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 @@ class AprsListenerThread(threading.Thread): 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 @@ class AprsListenerThread(threading.Thread): "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/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 34d5e4161fb..a87756334e2 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 @@ import homeassistant.helpers.config_validation as cv 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 @@ class SharpAquosTVDevice(MediaPlayerEntity): # 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/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 12114ec04b8..7c4ec280101 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 @@ from .const import ( 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 48b8d9f13c4..bb917af5c39 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -40,13 +40,13 @@ class ArrisDeviceScanner(DeviceScanner): 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 @@ class ArrisDeviceScanner(DeviceScanner): ) 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 7b8c547fd53..1b449450cf8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations 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 @@ class ArubaDeviceScanner(DeviceScanner): 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 @@ class ArubaDeviceScanner(DeviceScanner): ) 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 @@ class ArubaDeviceScanner(DeviceScanner): 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 d468a93eca0..caf7dc6f45e 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 @@ DATA_ARWN = "arwn" 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 @@ async def async_setup_platform( 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 @@ class ArwnSensor(SensorEntity): 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 cc91b6b97a6..e0b45ee6d4f 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -38,7 +38,6 @@ UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( AsekoBinarySensorEntityDescription( key="water_flow", translation_key="water_flow", - icon="mdi:waves-arrow-right", value_fn=lambda unit: unit.water_flow, ), AsekoBinarySensorEntityDescription( diff --git a/homeassistant/components/aseko_pool_live/icons.json b/homeassistant/components/aseko_pool_live/icons.json new file mode 100644 index 00000000000..2f8a77fc417 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "binary_sensor": { + "water_flow": { + "default": "mdi:waves-arrow-right" + } + }, + "sensor": { + "free_chlorine": { + "default": "mdi:flask" + }, + "water_temperature": { + "default": "mdi:coolant-temperature" + } + } + } +} diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 14eedd279b8..55a40195750 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -59,10 +59,8 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_native_unit_of_measurement = self._variable.unit self._attr_icon = { - "clf": "mdi:flask", "rx": "mdi:test-tube", "waterLevel": "mdi:waves", - "waterTemp": "mdi:coolant-temperature", }.get(self._variable.type) self._attr_device_class = { diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 71136dcdecb..a98f184094f 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1703,7 +1703,7 @@ class PipelineRuns: pipeline_run.abort_wake_word_detection = True -@dataclass +@dataclass(slots=True) class DeviceAudioQueue: """Audio capture queue for a satellite device.""" @@ -1717,6 +1717,14 @@ class DeviceAudioQueue: """Flag to be set if audio samples were dropped because the queue was full.""" +@dataclass(slots=True) +class AssistDevice: + """Assist device.""" + + domain: str + unique_id_prefix: str + + class PipelineData: """Store and debug data stored in hass.data.""" @@ -1724,12 +1732,12 @@ class PipelineData: """Initialize.""" self.pipeline_store = pipeline_store self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} - self.pipeline_devices: set[str] = set() + self.pipeline_devices: dict[str, AssistDevice] = {} self.pipeline_runs = PipelineRuns(pipeline_store) self.device_audio_queues: dict[str, DeviceAudioQueue] = {} -@dataclass +@dataclass(slots=True) class PipelineRunDebug: """Debug data for a pipelinerun.""" diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 83e1bd3ab36..43ed003f65d 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state from .const import DOMAIN -from .pipeline import PipelineData, PipelineStorageCollection +from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection from .vad import VadSensitivity OPTION_PREFERRED = "preferred" @@ -70,8 +70,10 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): _attr_current_option = OPTION_PREFERRED _attr_options = [OPTION_PREFERRED] - def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + def __init__(self, hass: HomeAssistant, domain: str, unique_id_prefix: str) -> None: """Initialize a pipeline selector.""" + self._domain = domain + self._unique_id_prefix = unique_id_prefix self._attr_unique_id = f"{unique_id_prefix}-pipeline" self.hass = hass self._update_options() @@ -91,11 +93,16 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): self._attr_current_option = state.state if self.registry_entry and (device_id := self.registry_entry.device_id): - pipeline_data.pipeline_devices.add(device_id) - self.async_on_remove( - lambda: pipeline_data.pipeline_devices.discard(device_id) + pipeline_data.pipeline_devices[device_id] = AssistDevice( + self._domain, self._unique_id_prefix ) + def cleanup() -> None: + """Clean up registered device.""" + pipeline_data.pipeline_devices.pop(device_id) + + self.async_on_remove(cleanup) + async def async_select_option(self, option: str) -> None: """Select an option.""" self._attr_current_option = option diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 89cced519df..bfba8563875 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api 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.helpers import config_validation as cv, entity_registry as er from homeassistant.util import language as language_util from .const import ( @@ -53,6 +53,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) 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_list_devices) websocket_api.async_register_command(hass, websocket_get_run) websocket_api.async_register_command(hass, websocket_device_capture) @@ -287,6 +288,35 @@ def websocket_list_runs( ) +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/device/list", + } +) +def websocket_list_devices( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List assist devices.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + ent_reg = er.async_get(hass) + connection.send_result( + msg["id"], + [ + { + "device_id": device_id, + "pipeline_entity": ent_reg.async_get_entity_id( + "select", info.domain, f"{info.unique_id_prefix}-pipeline" + ), + } + for device_id, info in pipeline_data.pipeline_devices.items() + ], + ) + + @callback @websocket_api.require_admin @websocket_api.websocket_command( diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index a6c246831af..971b893ef6b 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -3,6 +3,7 @@ from __future__ import annotations 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 @@ class AsteriskCDR(Mailbox): 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 607daad5b54..e4c80a5848d 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 @@ CONFIG_SCHEMA = vol.Schema( 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 @@ class AsteriskData: 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 @@ class AsteriskData: ) @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 @@ class AsteriskData: 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 @@ class AsteriskData: ) @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 edf95cb3787..95b3b7e3b15 100644 --- a/homeassistant/components/asterisk_mbox/mailbox.py +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -74,7 +74,7 @@ class AsteriskMailbox(Mailbox): 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 53a0b5d06b5..cc06c225d22 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession from pyasuswrt import AsusWrtError, AsusWrtHttp +from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError from homeassistant.const import ( CONF_HOST, @@ -354,13 +355,14 @@ class AsusWrtHttpBridge(AsusWrtBridge): 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_loadavg = await self._get_loadavg_sensors_availability() sensors_types = { SENSORS_TYPE_BYTES: { KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, SENSORS_TYPE_LOAD_AVG: { - KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_SENSORS: sensors_loadavg, KEY_METHOD: self._get_load_avg, }, SENSORS_TYPE_RATES: { @@ -393,6 +395,16 @@ class AsusWrtHttpBridge(AsusWrtBridge): return [] return available_sensors + async def _get_loadavg_sensors_availability(self) -> list[str]: + """Check if load avg is available on the router.""" + try: + await self._api.async_get_loadavg() + except AsusWrtNotAvailableInfoError: + return [] + except AsusWrtError: + pass + return SENSORS_LOAD_AVG + @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" diff --git a/homeassistant/components/asuswrt/icons.json b/homeassistant/components/asuswrt/icons.json new file mode 100644 index 00000000000..a4e44496a2f --- /dev/null +++ b/homeassistant/components/asuswrt/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "devices_connected": { + "default": "mdi:router-network" + }, + "download_speed": { + "default": "mdi:download-network" + }, + "upload_speed": { + "default": "mdi:upload-network" + }, + "download": { + "default": "mdi:download" + }, + "upload": { + "default": "mdi:upload" + }, + "load_avg_1m": { + "default": "mdi:cpu-32-bit" + }, + "load_avg_5m": { + "default": "mdi:cpu-32-bit" + }, + "load_avg_15m": { + "default": "mdi:cpu-32-bit" + } + } + } +} diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index f1296befbba..3399071daa4 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -51,14 +51,12 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_CONNECTED_DEVICE[0], translation_key="devices_connected", - icon="mdi:router-network", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], translation_key="download_speed", - icon="mdi:download-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, @@ -69,7 +67,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_RATES[1], translation_key="upload_speed", - icon="mdi:upload-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, @@ -80,7 +77,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_BYTES[0], translation_key="download", - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -91,7 +87,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_BYTES[1], translation_key="upload", - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -102,7 +97,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[0], translation_key="load_avg_1m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -111,7 +105,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[1], translation_key="load_avg_5m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -120,7 +113,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[2], translation_key="load_avg_15m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index 2d04ca798e0..b0cc83ab88e 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "atag" -PLATFORMS = [Platform.CLIMATE, Platform.WATER_HEATER, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 9b2729f141e..a5f119e3a2b 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -46,6 +46,7 @@ class AtagThermostat(AtagEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, atag_id): """Initialize an Atag climate device.""" diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index c1eb21b6827..624121b8828 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import ValuesView +from collections.abc import Callable, Coroutine, Iterable, ValuesView from datetime import datetime from itertools import chain import logging -from typing import Any +from typing import Any, ParamSpec, TypeVar from aiohttp import ClientError, ClientResponseError +from yalexs.activity import ActivityTypes from yalexs.const import DEFAULT_BRAND from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.exceptions import AugustApiAIOHTTPError @@ -34,6 +35,9 @@ from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin from .util import async_create_august_clientsession +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) API_CACHED_ATTRS = { @@ -104,7 +108,7 @@ async def async_setup_august( @callback def _async_trigger_ble_lock_discovery( hass: HomeAssistant, locks_with_offline_keys: list[LockDetail] -): +) -> None: """Update keys for the yalexs-ble integration if available.""" for lock_detail in locks_with_offline_keys: discovery_flow.async_create_flow( @@ -137,7 +141,7 @@ class AugustData(AugustSubscriberMixin): self._config_entry = config_entry self._hass = hass self._august_gateway = august_gateway - self.activity_stream: ActivityStream | None = None + self.activity_stream: ActivityStream = None # type: ignore[assignment] self._api = august_gateway.api self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {} self._doorbells_by_id: dict[str, Doorbell] = {} @@ -150,7 +154,7 @@ class AugustData(AugustSubscriberMixin): """Brand of the device.""" return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND) - async def async_setup(self): + async def async_setup(self) -> None: """Async setup of august device data and activities.""" token = self._august_gateway.access_token # This used to be a gather but it was less reliable with august's recent api changes. @@ -213,7 +217,7 @@ class AugustData(AugustSubscriberMixin): self._hass, self._async_initial_sync(), "august-initial-sync" ) - async def _async_initial_sync(self): + async def _async_initial_sync(self) -> None: """Attempt to request an initial sync.""" # We don't care if this fails because we only want to wake # locks that are actually online anyways and they will be @@ -245,16 +249,16 @@ class AugustData(AugustSubscriberMixin): device = self.get_device_detail(device_id) activities = activities_from_pubnub_message(device, date_time, message) activity_stream = self.activity_stream - assert activity_stream is not None if activities: activity_stream.async_process_newer_device_activities(activities) self.async_signal_device_id_update(device.device_id) activity_stream.async_schedule_house_id_refresh(device.house_id) @callback - def async_stop(self): + def async_stop(self) -> None: """Stop the subscriptions.""" - self._pubnub_unsub() + if self._pubnub_unsub: + self._pubnub_unsub() self.activity_stream.async_stop() @property @@ -271,10 +275,12 @@ class AugustData(AugustSubscriberMixin): """Return the py-august LockDetail or DoorbellDetail object for a device.""" return self._device_detail_by_id[device_id] - async def _async_refresh(self, time): + async def _async_refresh(self, time: datetime) -> None: await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) - async def _async_refresh_device_detail_by_ids(self, device_ids_list): + async def _async_refresh_device_detail_by_ids( + self, device_ids_list: Iterable[str] + ) -> None: """Refresh each device in sequence. This used to be a gather but it was less reliable with august's @@ -298,7 +304,7 @@ class AugustData(AugustSubscriberMixin): exc_info=err, ) - async def _async_refresh_device_detail_by_id(self, device_id): + async def _async_refresh_device_detail_by_id(self, device_id: str) -> None: if device_id in self._locks_by_id: if self.activity_stream and self.activity_stream.pubnub.connected: saved_attrs = _save_live_attrs(self._device_detail_by_id[device_id]) @@ -324,7 +330,13 @@ class AugustData(AugustSubscriberMixin): ) self.async_signal_device_id_update(device_id) - async def _async_update_device_detail(self, device, api_call): + async def _async_update_device_detail( + self, + device: Doorbell | Lock, + api_call: Callable[ + [str, str], Coroutine[Any, Any, DoorbellDetail | LockDetail] + ], + ) -> None: _LOGGER.debug( "Started retrieving detail for %s (%s)", device.device_name, @@ -358,7 +370,7 @@ class AugustData(AugustSubscriberMixin): return device.device_name return None - async def async_lock(self, device_id): + async def async_lock(self, device_id: str) -> list[ActivityTypes]: """Lock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -367,7 +379,7 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_status_async(self, device_id, hyper_bridge): + async def async_status_async(self, device_id: str, hyper_bridge: bool) -> str: """Request status of the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -377,7 +389,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_lock_async(self, device_id, hyper_bridge): + async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str: """Lock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -387,7 +399,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_unlock(self, device_id): + async def async_unlock(self, device_id: str) -> list[ActivityTypes]: """Unlock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -396,7 +408,7 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_unlock_async(self, device_id, hyper_bridge): + async def async_unlock_async(self, device_id: str, hyper_bridge: bool) -> str: """Unlock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -407,10 +419,13 @@ class AugustData(AugustSubscriberMixin): ) async def _async_call_api_op_requires_bridge( - self, device_id, func, *args, **kwargs - ): + self, + device_id: str, + func: Callable[_P, Coroutine[Any, Any, _R]], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> _R: """Call an API that requires the bridge to be online and will change the device state.""" - ret = None try: ret = await func(*args, **kwargs) except AugustApiAIOHTTPError as err: @@ -421,7 +436,7 @@ class AugustData(AugustSubscriberMixin): return ret - def _remove_inoperative_doorbells(self): + def _remove_inoperative_doorbells(self) -> None: for doorbell in list(self.doorbells): device_id = doorbell.device_id if self._device_detail_by_id.get(device_id): @@ -435,7 +450,7 @@ class AugustData(AugustSubscriberMixin): ) del self._doorbells_by_id[device_id] - def _remove_inoperative_locks(self): + def _remove_inoperative_locks(self) -> None: # Remove non-operative locks as there must # be a bridge (August Connect) for them to # be usable @@ -466,7 +481,7 @@ class AugustData(AugustSubscriberMixin): del self._locks_by_id[device_id] -def _save_live_attrs(lock_detail): +def _save_live_attrs(lock_detail: DoorbellDetail | LockDetail) -> dict[str, Any]: """Store the attributes that the lock detail api may have an invalid cache for. Since we are connected to pubnub we may have more current data @@ -476,7 +491,9 @@ def _save_live_attrs(lock_detail): return {attr: getattr(lock_detail, attr) for attr in API_CACHED_ATTRS} -def _restore_live_attrs(lock_detail, attrs): +def _restore_live_attrs( + lock_detail: DoorbellDetail | LockDetail, attrs: dict[str, Any] +) -> None: """Restore the non-cache attributes after a cached update.""" for attr, value in attrs.items(): setattr(lock_detail, attr, value) diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index fdb399f0646..fb87a1f7969 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,4 +1,6 @@ """Consume the august activity stream.""" +from __future__ import annotations + import asyncio from datetime import datetime from functools import partial diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 144666844e7..3c2ea5b3faa 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -140,7 +140,6 @@ SENSOR_TYPES_VIDEO_DOORBELL = ( AugustDoorbellBinarySensorEntityDescription( key="image capture", translation_key="image_capture", - icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), @@ -228,7 +227,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self._attr_unique_id = f"{self._device_id}_{description.key}" @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" assert self._data.activity_stream is not None door_activity = self._data.activity_stream.get_latest_device_activity( @@ -270,12 +269,12 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Initialize the sensor.""" super().__init__(data, device) self.entity_description = description - self._check_for_off_update_listener = None + self._check_for_off_update_listener: Callable[[], None] | None = None self._data = data self._attr_unique_id = f"{self._device_id}_{description.key}" @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" self._cancel_any_pending_updates() self._attr_is_on = self.entity_description.value_fn(self._data, self._detail) @@ -286,14 +285,14 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): else: self._attr_available = True - def _schedule_update_to_recheck_turn_off_sensor(self): + def _schedule_update_to_recheck_turn_off_sensor(self) -> None: """Schedule an update to recheck the sensor to see if it is ready to turn off.""" # If the sensor is already off there is nothing to do if not self.is_on: return @callback - def _scheduled_update(now): + def _scheduled_update(now: datetime) -> None: """Timer callback for sensor update.""" self._check_for_off_update_listener = None self._update_from_data() @@ -304,7 +303,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self.hass, TIME_TO_RECHECK_DETECTION.total_seconds(), _scheduled_update ) - def _cancel_any_pending_updates(self): + def _cancel_any_pending_updates(self) -> None: """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" if not self._check_for_off_update_listener: return diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index b8f66aea02b..3997a2d72bf 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -36,5 +36,5 @@ class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): await self._data.async_status_async(self._device_id, self._hyper_bridge) @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Nothing to update as buttons are stateless.""" diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index e618c2d49d5..e5835a69e07 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,7 +1,9 @@ """Support for August doorbell camera.""" from __future__ import annotations +from aiohttp import ClientSession from yalexs.activity import ActivityType +from yalexs.doorbell import Doorbell from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera @@ -37,7 +39,9 @@ class AugustCamera(AugustEntityMixin, Camera): _attr_translation_key = "camera" - def __init__(self, data, device, session, timeout): + def __init__( + self, data: AugustData, device: Doorbell, session: ClientSession, timeout: int + ) -> None: """Initialize an August security camera.""" super().__init__(data, device) self._timeout = timeout @@ -54,12 +58,12 @@ class AugustCamera(AugustEntityMixin, Camera): return self._device.has_subscription @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._detail.model @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" doorbell_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index f22b16008d3..8aaf1b1a05b 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -271,6 +271,4 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not existing_entry: return self.async_create_entry(title=info["title"], data=info["data"]) - self.hass.config_entries.async_update_entry(existing_entry, data=info["data"]) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(existing_entry, data=info["data"]) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index b97890d09b6..0cbd21f397e 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -50,9 +50,9 @@ LOGIN_METHODS = ["phone", "email"] DEFAULT_LOGIN_METHOD = "email" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, - Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR, ] diff --git a/homeassistant/components/august/diagnostics.py b/homeassistant/components/august/diagnostics.py index 6c19d57a0c3..57e56795c2d 100644 --- a/homeassistant/components/august/diagnostics.py +++ b/homeassistant/components/august/diagnostics.py @@ -24,6 +24,7 @@ TO_REDACT = { "remoteOperateSecret", "users", "zWaveDSK", + "contentToken", } diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index d149e035ac4..bcd2c6e2503 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -1,7 +1,7 @@ """Base class for August entity.""" from abc import abstractmethod -from yalexs.doorbell import Doorbell +from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.lock import Lock, LockDetail from yalexs.util import get_configuration_url @@ -42,28 +42,28 @@ class AugustEntityMixin(Entity): self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)} @property - def _device_id(self): + def _device_id(self) -> str: return self._device.device_id @property - def _detail(self): + def _detail(self) -> DoorbellDetail | LockDetail: return self._data.get_device_detail(self._device.device_id) @property - def _hyper_bridge(self): + def _hyper_bridge(self) -> bool: """Check if the lock has a paired hyper bridge.""" return bool(self._detail.bridge and self._detail.bridge.hyper_bridge) @callback - def _update_from_data_and_write_state(self): + def _update_from_data_and_write_state(self) -> None: self._update_from_data() self.async_write_ha_state() @abstractmethod - def _update_from_data(self): + def _update_from_data(self) -> None: """Update the entity state from the data object.""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to updates.""" self.async_on_remove( self._data.async_subscribe_device_id( @@ -77,7 +77,7 @@ class AugustEntityMixin(Entity): ) -def _remove_device_types(name, device_types): +def _remove_device_types(name: str, device_types: list[str]) -> str: """Strip device types from a string. August stores the name as Master Bed Lock diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index badff721d10..63bc085b811 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -34,19 +34,20 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" + api: ApiAsync + authenticator: AuthenticatorAsync + authentication: Authentication + _access_token_cache_file: str + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() - self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass self._config: Mapping[str, Any] | None = None - self.api: ApiAsync | None = None - self.authenticator: AuthenticatorAsync | None = None - self.authentication: Authentication | None = None @property - def access_token(self): + def access_token(self) -> str: """Access token for the api.""" return self.authentication.access_token @@ -97,9 +98,8 @@ class AugustGateway: await self.authenticator.async_setup_authentication() - async def async_authenticate(self): + async def async_authenticate(self) -> Authentication: """Authenticate with the details provided to setup.""" - self.authentication = None try: self.authentication = await self.authenticator.async_authenticate() if self.authentication.state == AuthenticationState.AUTHENTICATED: @@ -132,17 +132,17 @@ class AugustGateway: return self.authentication - async def async_reset_authentication(self): + async def async_reset_authentication(self) -> None: """Remove the cache file.""" await self._hass.async_add_executor_job(self._reset_authentication) - def _reset_authentication(self): + def _reset_authentication(self) -> None: """Remove the cache file.""" path = self._hass.config.path(self._access_token_cache_file) if os.path.exists(path): os.unlink(path) - async def async_refresh_access_token_if_needed(self): + async def async_refresh_access_token_if_needed(self) -> None: """Refresh the august access token if needed.""" if not self.authenticator.should_refresh(): return diff --git a/homeassistant/components/august/icons.json b/homeassistant/components/august/icons.json new file mode 100644 index 00000000000..b654b6d912a --- /dev/null +++ b/homeassistant/components/august/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "binary_sensor": { + "image_capture": { + "default": "mdi:file-image" + } + } + } +} diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index e082cd1cfab..93e0de018b0 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,10 +1,13 @@ """Support for August lock.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging from typing import Any from aiohttp import ClientResponseError -from yalexs.activity import SOURCE_PUBNUB, ActivityType -from yalexs.lock import LockStatus +from yalexs.activity import SOURCE_PUBNUB, ActivityType, ActivityTypes +from yalexs.lock import Lock, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity @@ -39,7 +42,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): _attr_name = None - def __init__(self, data, device): + def __init__(self, data: AugustData, device: Lock) -> None: """Initialize the lock.""" super().__init__(data, device) self._lock_status = None @@ -62,7 +65,9 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return await self._call_lock_operation(self._data.async_unlock) - async def _call_lock_operation(self, lock_operation): + async def _call_lock_operation( + self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]] + ) -> None: try: activities = await lock_operation(self._device_id) except ClientResponseError as err: @@ -82,7 +87,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): ) self._data.async_signal_device_id_update(self._device_id) - def _update_lock_status_from_detail(self): + def _update_lock_status_from_detail(self) -> bool: self._attr_available = self._detail.bridge_is_online if self._lock_status != self._detail.lock_status: @@ -91,7 +96,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return False @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" activity_stream = self._data.activity_stream device_id = self._device_id diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index d0f2a27522d..97963b19378 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.4.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.1"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 1896a91c54f..2cf0bb36d08 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar, cast -from yalexs.activity import ActivityType +from yalexs.activity import ActivityType, LockOperationActivity from yalexs.doorbell import Doorbell from yalexs.keypad import KeypadDetail from yalexs.lock import Lock, LockDetail @@ -158,7 +158,7 @@ async def async_setup_entry( async_add_entities(entities) -async def _async_migrate_old_unique_ids(hass, devices): +async def _async_migrate_old_unique_ids(hass: HomeAssistant, devices) -> None: """Keypads now have their own serial number.""" registry = er.async_get(hass) for device in devices: @@ -179,22 +179,22 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): _attr_translation_key = "operator" - def __init__(self, data, device): + def __init__(self, data: AugustData, device) -> None: """Initialize the sensor.""" super().__init__(data, device) self._data = data self._device = device - self._operated_remote = None - self._operated_keypad = None - self._operated_manual = None - self._operated_tag = None - self._operated_autorelock = None + self._operated_remote: bool | None = None + self._operated_keypad: bool | None = None + self._operated_manual: bool | None = None + self._operated_tag: bool | None = None + self._operated_autorelock: bool | None = None self._operated_time = None self._attr_unique_id = f"{self._device_id}_lock_operator" self._update_from_data() @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" lock_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.LOCK_OPERATION} @@ -202,6 +202,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._attr_available = True if lock_activity is not None: + lock_activity = cast(LockOperationActivity, lock_activity) self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad @@ -211,9 +212,9 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._attr_entity_picture = lock_activity.operator_thumbnail_url @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" - attributes = {} + attributes: dict[str, Any] = {} if self._operated_remote is not None: attributes[ATTR_OPERATION_REMOTE] = self._operated_remote @@ -291,7 +292,7 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): self._update_from_data() @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" self._attr_native_value = self.entity_description.value_fn(self._detail) self._attr_available = self._attr_native_value is not None diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 138887ed09e..9b4e118b83e 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -1,11 +1,11 @@ """Base class for August entity.""" - +from __future__ import annotations from abc import abstractmethod from datetime import datetime, timedelta from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval @@ -34,7 +34,7 @@ class AugustSubscriberMixin: self._subscriptions.setdefault(device_id, []).append(update_callback) - def _unsubscribe(): + def _unsubscribe() -> None: self.async_unsubscribe_device_id(device_id, update_callback) return _unsubscribe @@ -54,9 +54,10 @@ class AugustSubscriberMixin: ) @callback - def _async_cancel_update_interval(_): + def _async_cancel_update_interval(_: Event) -> None: self._stop_interval = None - self._unsub_interval() + if self._unsub_interval: + self._unsub_interval() self._stop_interval = self._hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, _async_cancel_update_interval diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index d817ea51988..94e1f3fc2da 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -1,4 +1,6 @@ """Support for Aurora Forecast binary sensor.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -17,7 +19,6 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, translation_key="visibility_alert", - icon="mdi:hazard-lights", ) async_add_entries([entity]) @@ -27,6 +28,6 @@ class AuroraSensor(AuroraEntity, BinarySensorEntity): """Implementation of an aurora sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if aurora is visible.""" return self.coordinator.data > self.coordinator.threshold diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 95e66ff226e..a1971884ead 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -51,7 +51,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: longitude = user_input[CONF_LONGITUDE] diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 9d4eb0aa681..8195f6d30ec 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,4 +1,5 @@ """The aurora component.""" +from __future__ import annotations from datetime import timedelta import logging @@ -12,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) -class AuroraDataUpdateCoordinator(DataUpdateCoordinator): +class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): """Class to manage fetching data from the NOAA Aurora API.""" def __init__( @@ -37,7 +38,7 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): self.longitude = int(longitude) self.threshold = int(threshold) - async def _async_update_data(self): + async def _async_update_data(self) -> int: """Fetch the data from the NOAA Aurora Forecast.""" try: diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 1b7dfbe88e3..3aa917862fb 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -20,7 +20,6 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self, coordinator: AuroraDataUpdateCoordinator, translation_key: str, - icon: str, ) -> None: """Initialize the Aurora Entity.""" @@ -28,7 +27,6 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" - self._attr_icon = icon self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._attr_unique_id)}, diff --git a/homeassistant/components/aurora/icons.json b/homeassistant/components/aurora/icons.json new file mode 100644 index 00000000000..64f9c85c31f --- /dev/null +++ b/homeassistant/components/aurora/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "binary_sensor": { + "visibility_alert": { + "default": "mdi:hazard-lights" + } + }, + "sensor": { + "visibility": { + "default": "mdi:gauge" + } + } + } +} diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index f44cc23f832..7801a84d58b 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,4 +1,6 @@ """Support for Aurora Forecast sensor.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE @@ -18,7 +20,6 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, translation_key="visibility", - icon="mdi:gauge", ) async_add_entries([entity]) @@ -31,6 +32,6 @@ class AuroraSensor(AuroraEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT @property - def native_value(self): + def native_value(self) -> int: """Return % chance the aurora is visible.""" return self.coordinator.data diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 39abba4ada5..c7400f31727 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -52,7 +52,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): +class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching AuroraAbbPowerone data.""" def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 07741bd4e3c..32295c3bf47 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -10,13 +10,12 @@ import serial.tools.list_ports import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_ADDRESS, DEFAULT_INTEGRATION_TITLE, DOMAIN, diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py index d1266a838c3..904f103d1c3 100644 --- a/homeassistant/components/aurora_abb_powerone/const.py +++ b/homeassistant/components/aurora_abb_powerone/const.py @@ -20,6 +20,5 @@ MANUFACTURER = "ABB" ATTR_DEVICE_NAME = "device_name" ATTR_DEVICE_ID = "device_id" -ATTR_SERIAL_NUMBER = "serial_number" ATTR_MODEL = "model" ATTR_FIRMWARE = "firmware" diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 80b0fd656b6..2ca7fa3e7ef 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_SERIAL_NUMBER, EntityCategory, UnitOfEnergy, UnitOfPower, @@ -31,7 +32,6 @@ from .const import ( ATTR_DEVICE_NAME, ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN, MANUFACTURER, diff --git a/homeassistant/components/aussie_broadband/icons.json b/homeassistant/components/aussie_broadband/icons.json new file mode 100644 index 00000000000..2b1a2439904 --- /dev/null +++ b/homeassistant/components/aussie_broadband/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "data_used": { + "default": "mdi:network" + }, + "downloaded": { + "default": "mdi:download-network" + }, + "uploaded": { + "default": "mdi:upload-network" + }, + "national_calls": { + "default": "mdi:phone" + }, + "mobile_calls": { + "default": "mdi:phone" + }, + "international_calls": { + "default": "mdi:phone-plus" + }, + "sms_sent": { + "default": "mdi:message-processing" + }, + "voicemail_calls": { + "default": "mdi:phone" + }, + "other_calls": { + "default": "mdi:phone" + }, + "billing_cycle_length": { + "default": "mdi:calendar-range" + }, + "billing_cycle_remaining": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index efc8ae99ef9..d92ba503412 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -38,7 +38,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:network", ), SensorValueEntityDescription( key="downloadedMb", @@ -46,7 +45,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download-network", ), SensorValueEntityDescription( key="uploadedMb", @@ -54,21 +52,18 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload-network", ), # Mobile Phone Services sensors SensorValueEntityDescription( key="national", translation_key="national_calls", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="mobile", translation_key="mobile_calls", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -76,14 +71,12 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="international_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone-plus", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="sms", translation_key="sms_sent", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:message-processing", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -92,7 +85,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.KILOBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:network", value=lambda x: x.get("kbytes"), ), SensorValueEntityDescription( @@ -100,7 +92,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="voicemail_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -108,7 +99,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="other_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), # Generic sensors @@ -116,13 +106,11 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( key="daysTotal", translation_key="billing_cycle_length", native_unit_of_measurement=UnitOfTime.DAYS, - icon="mdi:calendar-range", ), SensorValueEntityDescription( key="daysRemaining", translation_key="billing_cycle_remaining", native_unit_of_measurement=UnitOfTime.DAYS, - icon="mdi:calendar-clock", ), ) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 78a1383012d..f97647fff0e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -124,7 +124,6 @@ as part of a config flow. """ from __future__ import annotations -import asyncio from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus @@ -220,12 +219,12 @@ class RevokeTokenView(HomeAssistantView): if (token := data.get("token")) is None: return web.Response(status=HTTPStatus.OK) - refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + refresh_token = hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: return web.Response(status=HTTPStatus.OK) - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) return web.Response(status=HTTPStatus.OK) @@ -355,7 +354,7 @@ class TokenView(HomeAssistantView): {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST ) - refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + refresh_token = hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: return self.json( @@ -597,7 +596,7 @@ async def websocket_delete_refresh_token( connection.send_error(msg["id"], "invalid_token_id", "Received invalid token") return - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) connection.send_result(msg["id"], {}) @@ -605,6 +604,8 @@ async def websocket_delete_refresh_token( @websocket_api.websocket_command( { vol.Required("type"): "auth/delete_all_refresh_tokens", + vol.Optional("token_type"): cv.string, + vol.Optional("delete_current_token", default=True): bool, } ) @websocket_api.ws_require_user() @@ -613,28 +614,29 @@ async def websocket_delete_all_refresh_tokens( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete all refresh tokens request.""" - tasks = [] current_refresh_token: RefreshToken - for token in connection.user.refresh_tokens.values(): + remove_failed = False + token_type = msg.get("token_type") + delete_current_token = msg.get("delete_current_token") + limit_token_types = token_type is not None + + for token in list(connection.user.refresh_tokens.values()): if token.id == connection.refresh_token_id: # Skip the current refresh token as it has revoke_callback, # which cancels/closes the connection. # It will be removed after sending the result. current_refresh_token = token continue - tasks.append( - hass.async_create_task(hass.auth.async_remove_refresh_token(token)) - ) - - remove_failed = False - if tasks: - for result in await asyncio.gather(*tasks, return_exceptions=True): - if isinstance(result, Exception): - getLogger(__name__).exception( - "During refresh token removal, the following error occurred: %s", - result, - ) - remove_failed = True + if limit_token_types and token_type != token.token_type: + continue + try: + hass.auth.async_remove_refresh_token(token) + except Exception as err: # pylint: disable=broad-except + getLogger(__name__).exception( + "During refresh token removal, the following error occurred: %s", + err, + ) + remove_failed = True if remove_failed: connection.send_error( @@ -643,7 +645,11 @@ async def websocket_delete_all_refresh_tokens( else: connection.send_result(msg["id"], {}) - hass.async_create_task(hass.auth.async_remove_refresh_token(current_refresh_token)) + if delete_current_token and ( + not limit_token_types or current_refresh_token.token_type == token_type + ): + # This will close the connection so we need to send the result first. + hass.loop.call_soon(hass.auth.async_remove_refresh_token, current_refresh_token) @websocket_api.websocket_command( diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 9b96e57dbd3..cc6cb5fc47a 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,6 +91,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local from . import indieauth @@ -185,7 +186,14 @@ class AuthProvidersView(HomeAssistantView): } ) - return self.json(providers) + preselect_remember_me = not cloud_connection and is_local(remote_address) + + return self.json( + { + "providers": providers, + "preselect_remember_me": preselect_remember_me, + } + ) def _prepare_result_json( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index efad44b15ef..dbf76a1fe59 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import partial import logging -from typing import Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol @@ -72,6 +72,7 @@ from homeassistant.helpers.script import ( CONF_MAX, CONF_MAX_EXCEEDED, Script, + ScriptRunResult, script_stack_cv, ) from homeassistant.helpers.script_variables import ScriptVariables @@ -110,6 +111,12 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_automation +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -333,7 +340,7 @@ class BaseAutomationEntity(ToggleEntity, ABC): return {CONF_ID: self.unique_id} return None - @property + @cached_property @abstractmethod def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" @@ -343,12 +350,12 @@ class BaseAutomationEntity(ToggleEntity, ABC): def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - @property + @cached_property @abstractmethod def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - @property + @cached_property @abstractmethod def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -359,7 +366,7 @@ class BaseAutomationEntity(ToggleEntity, ABC): run_variables: dict[str, Any], context: Context | None = None, skip_condition: bool = False, - ) -> None: + ) -> ScriptRunResult | None: """Trigger automation.""" @@ -388,7 +395,7 @@ class UnavailableAutomationEntity(BaseAutomationEntity): """Return the name of the entity.""" return self._name - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return set() @@ -398,12 +405,12 @@ class UnavailableAutomationEntity(BaseAutomationEntity): """Return referenced blueprint or None.""" return None - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return set() - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return set() @@ -445,8 +452,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.action_script.change_listener = self.async_write_ha_state self._initial_state = initial_state self._is_enabled = False - self._referenced_entities: set[str] | None = None - self._referenced_devices: set[str] | None = None self._logger = LOGGER self._variables = variables self._trigger_variables = trigger_variables @@ -477,7 +482,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): """Return True if entity is on.""" return self._async_detach_triggers is not None or self._is_enabled - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return self.action_script.referenced_areas @@ -489,12 +494,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - if self._referenced_devices is not None: - return self._referenced_devices - referenced = self.action_script.referenced_devices if self._cond_func is not None: @@ -504,15 +506,11 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): for conf in self._trigger_config: referenced |= set(_trigger_extract_devices(conf)) - self._referenced_devices = referenced return referenced - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" - if self._referenced_entities is not None: - return self._referenced_entities - referenced = self.action_script.referenced_entities if self._cond_func is not None: @@ -523,7 +521,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): for entity_id in _trigger_extract_entities(conf): referenced.add(entity_id) - self._referenced_entities = referenced return referenced async def async_added_to_hass(self) -> None: @@ -581,7 +578,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): run_variables: dict[str, Any], context: Context | None = None, skip_condition: bool = False, - ) -> None: + ) -> ScriptRunResult | None: """Trigger automation. This method is a coroutine. @@ -617,7 +614,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): except TemplateError as err: self._logger.error("Error rendering variables: %s", err) automation_trace.set_error(err) - return + return None # Prepare tracing the automation automation_trace.set_trace(trace_get()) @@ -644,7 +641,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): trace_get(clear=False), ) script_execution_set("failed_conditions") - return + return None self.async_set_context(trigger_context) event_data = { @@ -666,7 +663,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): try: with trace_path("action"): - await self.action_script.async_run( + return await self.action_script.async_run( variables, trigger_context, started_action ) except ServiceNotFound as err: @@ -697,6 +694,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self._logger.exception("While executing automation %s", self.entity_id) automation_trace.set_error(err) + return None + async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() @@ -721,7 +720,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self._is_enabled = True # HomeAssistant is starting up - if self.hass.state != CoreState.not_running: + if self.hass.state is not CoreState.not_running: self._async_detach_triggers = await self._async_attach_triggers(False) self.async_write_ha_state() return diff --git a/homeassistant/components/automation/icons.json b/homeassistant/components/automation/icons.json new file mode 100644 index 00000000000..9b68825ffd1 --- /dev/null +++ b/homeassistant/components/automation/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot", + "state": { + "off": "mdi:robot-off", + "unavailable": "mdi:robot-confused" + } + } + }, + "services": { + "turn_on": "mdi:robot", + "turn_off": "mdi:robot-off", + "toggle": "mdi:robot", + "trigger": "mdi:robot", + "reload": "mdi:reload" + } +} diff --git a/homeassistant/components/awair/icons.json b/homeassistant/components/awair/icons.json new file mode 100644 index 00000000000..895cac81869 --- /dev/null +++ b/homeassistant/components/awair/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "score": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 698850d6a49..03243e51b7c 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -64,7 +64,6 @@ class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMix SENSOR_TYPE_SCORE = AwairSensorEntityDescription( key=API_SCORE, - icon="mdi:blur", native_unit_of_measurement=PERCENTAGE, translation_key="score", unique_id_tag="score", # matches legacy format @@ -96,7 +95,6 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( ), AwairSensorEntityDescription( key=API_VOC, - icon="mdi:molecule", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, unique_id_tag="VOC", # matches legacy format diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index c93a8493845..61caf4c2318 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aws", "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], - "requirements": ["aiobotocore==2.6.0"] + "requirements": ["aiobotocore==2.9.1"] } diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 4cc81947e27..d68de7742dc 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 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): 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 0c132814e39..67ef61af8ac 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -24,7 +24,7 @@ from homeassistant.const import ( 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 @@ class AxisNetworkDevice: 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 @@ class AxisNetworkDevice: 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 @@ class AxisNetworkDevice: # 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 @@ class AxisNetworkDevice: 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 5a1fede53c7..81f0b1678fb 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -42,7 +42,7 @@ class AxisEntity(Entity): 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/baf/climate.py b/homeassistant/components/baf/climate.py index 531659e901f..907e8ff2356 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -33,10 +33,15 @@ async def async_setup_entry( class BAFAutoComfort(BAFEntity, ClimateEntity): """BAF climate auto comfort.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_translation_key = "auto_comfort" + _enable_turn_on_off_backwards_compatibility = False @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index d213a8fd2e8..b9cce73de75 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -56,10 +56,14 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): _attr_icon = "mdi:hot-tub" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py new file mode 100644 index 00000000000..3071b8fc6b2 --- /dev/null +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -0,0 +1,85 @@ +"""The Bang & Olufsen integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MODEL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.device_registry as dr + +from .const import DOMAIN +from .websocket import BangOlufsenWebsocket + + +@dataclass +class BangOlufsenData: + """Dataclass for API client and WebSocket client.""" + + websocket: BangOlufsenWebsocket + client: MozartClient + + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + # Remove casts to str + assert entry.unique_id + + # Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + name=entry.title, + model=entry.data[CONF_MODEL], + ) + + client = MozartClient(host=entry.data[CONF_HOST], websocket_reconnect=True) + + # Check connection and try to initialize it. + try: + await client.get_battery_state(_request_timeout=3) + except (ApiException, ClientConnectorError, TimeoutError) as error: + await client.close_api_client() + raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error + + websocket = BangOlufsenWebsocket(hass, entry, client) + + # Add the websocket and API client + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BangOlufsenData( + websocket, + client, + ) + + # Check and start WebSocket connection + if not await client.connect_notifications(remote_control=True): + raise ConfigEntryNotReady( + f"Unable to connect to {entry.title} WebSocket notification channel" + ) + + 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.""" + # Close the API client and WebSocket notification listener + hass.data[DOMAIN][entry.entry_id].client.disconnect_notifications() + await hass.data[DOMAIN][entry.entry_id].client.close_api_client() + + 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/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py new file mode 100644 index 00000000000..6a26c4c5984 --- /dev/null +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -0,0 +1,184 @@ +"""Config flow for the Bang & Olufsen integration.""" +from __future__ import annotations + +from ipaddress import AddressValueError, IPv4Address +from typing import Any, TypedDict + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +from mozart_api.mozart_client import MozartClient +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import ( + ATTR_FRIENDLY_NAME, + ATTR_ITEM_NUMBER, + ATTR_SERIAL_NUMBER, + ATTR_TYPE_NUMBER, + COMPATIBLE_MODELS, + CONF_SERIAL_NUMBER, + DEFAULT_MODEL, + DOMAIN, +) + + +class EntryData(TypedDict, total=False): + """TypedDict for config_entry data.""" + + host: str + jid: str + model: str + name: str + + +# Map exception types to strings +_exception_map = { + ApiException: "api_exception", + ClientConnectorError: "client_connector_error", + TimeoutError: "timeout_error", + AddressValueError: "invalid_ip", +} + + +class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + _beolink_jid = "" + _client: MozartClient + _host = "" + _model = "" + _name = "" + _serial_number = "" + + def __init__(self) -> None: + """Init the config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_MODEL, default=DEFAULT_MODEL): SelectSelector( + SelectSelectorConfig(options=COMPATIBLE_MODELS) + ), + } + ) + + if user_input is not None: + self._host = user_input[CONF_HOST] + self._model = user_input[CONF_MODEL] + + # Check if the IP address is a valid IPv4 address. + try: + IPv4Address(self._host) + except AddressValueError as error: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": _exception_map[type(error)]}, + ) + + self._client = MozartClient(self._host) + + # Try to get information from Beolink self method. + async with self._client: + try: + beolink_self = await self._client.get_beolink_self( + _request_timeout=3 + ) + except ( + ApiException, + ClientConnectorError, + TimeoutError, + ) as error: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": _exception_map[type(error)]}, + ) + + self._beolink_jid = beolink_self.jid + self._serial_number = beolink_self.jid.split(".")[2].split("@")[0] + + await self.async_set_unique_id(self._serial_number) + self._abort_if_unique_id_configured() + + return await self._create_entry() + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: + """Handle discovery using Zeroconf.""" + + # Check if the discovered device is a Mozart device + if ATTR_FRIENDLY_NAME not in discovery_info.properties: + return self.async_abort(reason="not_mozart_device") + + # Ensure that an IPv4 address is received + self._host = discovery_info.host + try: + IPv4Address(self._host) + except AddressValueError: + return self.async_abort(reason="ipv6_address") + + self._model = discovery_info.hostname[:-16].replace("-", " ") + self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER] + self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com" + + await self.async_set_unique_id(self._serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + # Set the discovered device title + self.context["title_placeholders"] = { + "name": discovery_info.properties[ATTR_FRIENDLY_NAME] + } + + return await self.async_step_zeroconf_confirm() + + async def _create_entry(self) -> FlowResult: + """Create the config entry for a discovered or manually configured Bang & Olufsen device.""" + # Ensure that created entities have a unique and easily identifiable id and not a "friendly name" + self._name = f"{self._model}-{self._serial_number}" + + return self.async_create_entry( + title=self._name, + data=EntryData( + host=self._host, + jid=self._beolink_jid, + model=self._model, + name=self._name, + ), + ) + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the configuration of the device.""" + if user_input is not None: + return await self._create_entry() + + self._set_confirm_only() + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + CONF_HOST: self._host, + CONF_MODEL: self._model, + CONF_SERIAL_NUMBER: self._serial_number, + }, + last_step=True, + ) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py new file mode 100644 index 00000000000..3a6638fe31a --- /dev/null +++ b/homeassistant/components/bang_olufsen/const.py @@ -0,0 +1,207 @@ +"""Constants for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Final + +from mozart_api.models import Source, SourceArray, SourceTypeEnum + +from homeassistant.components.media_player import MediaPlayerState, MediaType + + +class SOURCE_ENUM(StrEnum): + """Enum used for associating device source ids with friendly names. May not include all sources.""" + + uriStreamer = "Audio Streamer" # noqa: N815 + bluetooth = "Bluetooth" + airPlay = "AirPlay" # noqa: N815 + chromeCast = "Chromecast built-in" # noqa: N815 + spotify = "Spotify Connect" + generator = "Tone Generator" + lineIn = "Line-In" # noqa: N815 + spdif = "Optical" + netRadio = "B&O Radio" # noqa: N815 + local = "Local" + dlna = "DLNA" + qplay = "QPlay" + wpl = "Wireless Powerlink" + pl = "Powerlink" + tv = "TV" + deezer = "Deezer" + beolink = "Networklink" + tidalConnect = "Tidal Connect" # noqa: N815 + + +BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { + # Dict used for translating device states to Home Assistant states. + "started": MediaPlayerState.PLAYING, + "buffering": MediaPlayerState.PLAYING, + "idle": MediaPlayerState.IDLE, + "paused": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.PAUSED, + "ended": MediaPlayerState.PAUSED, + "error": MediaPlayerState.IDLE, + # A device's initial state is "unknown" and should be treated as "idle" + "unknown": MediaPlayerState.IDLE, +} + + +# Media types for play_media +class BANG_OLUFSEN_MEDIA_TYPE(StrEnum): + """Bang & Olufsen specific media types.""" + + FAVOURITE = "favourite" + DEEZER = "deezer" + RADIO = "radio" + TTS = "provider" + + +class MODEL_ENUM(StrEnum): + """Enum for compatible model names.""" + + BEOLAB_8 = "BeoLab 8" + BEOLAB_28 = "BeoLab 28" + BEOSOUND_2 = "Beosound 2 3rd Gen" + BEOSOUND_A5 = "Beosound A5" + BEOSOUND_A9 = "Beosound A9 5th Gen" + BEOSOUND_BALANCE = "Beosound Balance" + BEOSOUND_EMERGE = "Beosound Emerge" + BEOSOUND_LEVEL = "Beosound Level" + BEOSOUND_THEATRE = "Beosound Theatre" + + +# Dispatcher events +class WEBSOCKET_NOTIFICATION(StrEnum): + """Enum for WebSocket notification types.""" + + PLAYBACK_ERROR: Final[str] = "playback_error" + PLAYBACK_METADATA: Final[str] = "playback_metadata" + PLAYBACK_PROGRESS: Final[str] = "playback_progress" + PLAYBACK_SOURCE: Final[str] = "playback_source" + PLAYBACK_STATE: Final[str] = "playback_state" + SOFTWARE_UPDATE_STATE: Final[str] = "software_update_state" + SOURCE_CHANGE: Final[str] = "source_change" + VOLUME: Final[str] = "volume" + + # Sub-notifications + NOTIFICATION: Final[str] = "notification" + REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged" + + ALL: Final[str] = "all" + + +DOMAIN: Final[str] = "bang_olufsen" + +# Default values for configuration. +DEFAULT_MODEL: Final[str] = MODEL_ENUM.BEOSOUND_BALANCE + +# Configuration. +CONF_SERIAL_NUMBER: Final = "serial_number" +CONF_BEOLINK_JID: Final = "jid" + +# Models to choose from in manual configuration. +COMPATIBLE_MODELS: list[str] = [x.value for x in MODEL_ENUM] + +# Attribute names for zeroconf discovery. +ATTR_TYPE_NUMBER: Final[str] = "tn" +ATTR_SERIAL_NUMBER: Final[str] = "sn" +ATTR_ITEM_NUMBER: Final[str] = "in" +ATTR_FRIENDLY_NAME: Final[str] = "fn" + +# Power states. +BANG_OLUFSEN_ON: Final[str] = "on" + +VALID_MEDIA_TYPES: Final[tuple] = ( + BANG_OLUFSEN_MEDIA_TYPE.FAVOURITE, + BANG_OLUFSEN_MEDIA_TYPE.DEEZER, + BANG_OLUFSEN_MEDIA_TYPE.RADIO, + BANG_OLUFSEN_MEDIA_TYPE.TTS, + MediaType.MUSIC, + MediaType.URL, + MediaType.CHANNEL, +) + +# Sources on the device that should not be selectable by the user +HIDDEN_SOURCE_IDS: Final[tuple] = ( + "airPlay", + "bluetooth", + "chromeCast", + "generator", + "local", + "dlna", + "qplay", + "wpl", + "pl", + "beolink", + "usbIn", +) + +# Fallback sources to use in case of API failure. +FALLBACK_SOURCES: Final[SourceArray] = SourceArray( + items=[ + Source( + id="uriStreamer", + is_enabled=True, + is_playable=False, + name="Audio Streamer", + type=SourceTypeEnum(value="uriStreamer"), + ), + Source( + id="bluetooth", + is_enabled=True, + is_playable=False, + name="Bluetooth", + type=SourceTypeEnum(value="bluetooth"), + ), + Source( + id="spotify", + is_enabled=True, + is_playable=False, + name="Spotify Connect", + type=SourceTypeEnum(value="spotify"), + ), + Source( + id="lineIn", + is_enabled=True, + is_playable=True, + name="Line-In", + type=SourceTypeEnum(value="lineIn"), + ), + Source( + id="spdif", + is_enabled=True, + is_playable=True, + name="Optical", + type=SourceTypeEnum(value="spdif"), + ), + Source( + id="netRadio", + is_enabled=True, + is_playable=True, + name="B&O Radio", + type=SourceTypeEnum(value="netRadio"), + ), + Source( + id="deezer", + is_enabled=True, + is_playable=True, + name="Deezer", + type=SourceTypeEnum(value="deezer"), + ), + Source( + id="tidalConnect", + is_enabled=True, + is_playable=True, + name="Tidal Connect", + type=SourceTypeEnum(value="tidalConnect"), + ), + ] +) + + +# Device events +BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" + + +CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" diff --git a/homeassistant/components/bang_olufsen/entity.py b/homeassistant/components/bang_olufsen/entity.py new file mode 100644 index 00000000000..76d93ca0635 --- /dev/null +++ b/homeassistant/components/bang_olufsen/entity.py @@ -0,0 +1,71 @@ +"""Entity representing a Bang & Olufsen device.""" +from __future__ import annotations + +from typing import cast + +from mozart_api.models import ( + PlaybackContentMetadata, + PlaybackProgress, + RenderingState, + Source, + VolumeLevel, + VolumeMute, + VolumeState, +) +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class BangOlufsenBase: + """Base class for BangOlufsen Home Assistant objects.""" + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the object.""" + + # Set the MozartClient + self._client = client + + # get the input from the config entry. + self.entry: ConfigEntry = entry + + # Set the configuration variables. + self._host: str = self.entry.data[CONF_HOST] + self._name: str = self.entry.title + self._unique_id: str = cast(str, self.entry.unique_id) + + # Objects that get directly updated by notifications. + self._playback_metadata: PlaybackContentMetadata = PlaybackContentMetadata() + self._playback_progress: PlaybackProgress = PlaybackProgress(total_duration=0) + self._playback_source: Source = Source() + self._playback_state: RenderingState = RenderingState() + self._source_change: Source = Source() + self._volume: VolumeState = VolumeState( + level=VolumeLevel(level=0), muted=VolumeMute(muted=False) + ) + + +class BangOlufsenEntity(Entity, BangOlufsenBase): + """Base Entity for BangOlufsen entities.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the object.""" + super().__init__(entry, client) + + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) + self._attr_device_class = None + self._attr_entity_category = None + self._attr_should_poll = False + + async def _update_connection_state(self, connection_state: bool) -> None: + """Update entity connection state.""" + self._attr_available = connection_state + + self.async_write_ha_state() diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json new file mode 100644 index 00000000000..3c920a99d7f --- /dev/null +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bang_olufsen", + "name": "Bang & Olufsen", + "codeowners": ["@mj23000"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["mozart-api==3.2.1.150.6"], + "zeroconf": ["_bangolufsen._tcp.local."] +} diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py new file mode 100644 index 00000000000..869cabc5a4a --- /dev/null +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -0,0 +1,647 @@ +"""Media player entity for the Bang & Olufsen integration.""" +from __future__ import annotations + +import json +import logging +from typing import Any, cast + +from mozart_api import __version__ as MOZART_API_VERSION +from mozart_api.exceptions import ApiException +from mozart_api.models import ( + Action, + Art, + OverlayPlayRequest, + PlaybackContentMetadata, + PlaybackError, + PlaybackProgress, + PlayQueueItem, + PlayQueueItemType, + RenderingState, + SceneProperties, + SoftwareUpdateState, + SoftwareUpdateStatus, + Source, + Uri, + UserFlow, + VolumeLevel, + VolumeMute, + VolumeState, +) +from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + ATTR_MEDIA_EXTRA, + BrowseMedia, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, + async_process_play_media_url, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import BangOlufsenData +from .const import ( + BANG_OLUFSEN_MEDIA_TYPE, + BANG_OLUFSEN_STATES, + CONF_BEOLINK_JID, + CONNECTION_STATUS, + DOMAIN, + FALLBACK_SOURCES, + HIDDEN_SOURCE_IDS, + SOURCE_ENUM, + VALID_MEDIA_TYPES, + WEBSOCKET_NOTIFICATION, +) +from .entity import BangOlufsenEntity + +_LOGGER = logging.getLogger(__name__) + +BANG_OLUFSEN_FEATURES = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.TURN_OFF +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Media Player entity from config entry.""" + data: BangOlufsenData = hass.data[DOMAIN][config_entry.entry_id] + + # Add MediaPlayer entity + async_add_entities(new_entities=[BangOlufsenMediaPlayer(config_entry, data.client)]) + + +class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): + """Representation of a media player.""" + + _attr_has_entity_name = False + _attr_icon = "mdi:speaker-wireless" + _attr_supported_features = BANG_OLUFSEN_FEATURES + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the media player.""" + super().__init__(entry, client) + + self._beolink_jid: str = self.entry.data[CONF_BEOLINK_JID] + self._model: str = self.entry.data[CONF_MODEL] + + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{self._host}/#/", + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="Bang & Olufsen", + model=self._model, + name=cast(str, self.name), + serial_number=self._unique_id, + ) + self._attr_name = self._name + self._attr_unique_id = self._unique_id + self._attr_device_class = MediaPlayerDeviceClass.SPEAKER + + # Misc. variables. + self._audio_sources: dict[str, str] = {} + self._media_image: Art = Art() + self._software_status: SoftwareUpdateStatus = SoftwareUpdateStatus( + software_version="", + state=SoftwareUpdateState(seconds_remaining=0, value="idle"), + ) + self._sources: dict[str, str] = {} + self._state: str = MediaPlayerState.IDLE + self._video_sources: dict[str, str] = {} + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + await self._initialize() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._update_connection_state, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}", + self._update_playback_error, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}", + self._update_playback_metadata, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}", + self._update_playback_progress, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}", + self._update_playback_state, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}", + self._update_sources, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}", + self._update_source_change, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}", + self._update_volume, + ) + ) + + async def _initialize(self) -> None: + """Initialize connection dependent variables.""" + + # Get software version. + self._software_status = await self._client.get_softwareupdate_status() + + _LOGGER.debug( + "Connected to: %s %s running SW %s", + self._model, + self._unique_id, + self._software_status.software_version, + ) + + # Get overall device state once. This is handled by WebSocket events the rest of the time. + product_state = await self._client.get_product_state() + + # Get volume information. + if product_state.volume: + self._volume = product_state.volume + + # Get all playback information. + # Ensure that the metadata is not None upon startup + if product_state.playback: + if product_state.playback.metadata: + self._playback_metadata = product_state.playback.metadata + if product_state.playback.progress: + self._playback_progress = product_state.playback.progress + if product_state.playback.source: + self._source_change = product_state.playback.source + if product_state.playback.state: + self._playback_state = product_state.playback.state + # Set initial state + if self._playback_state.value: + self._state = self._playback_state.value + + self._attr_media_position_updated_at = utcnow() + + # Get the highest resolution available of the given images. + self._media_image = get_highest_resolution_artwork(self._playback_metadata) + + # If the device has been updated with new sources, then the API will fail here. + await self._update_sources() + + # Set the static entity attributes that needed more information. + self._attr_source_list = list(self._sources.values()) + + async def _update_sources(self) -> None: + """Get sources for the specific product.""" + + # Audio sources + try: + # Get all available sources. + sources = await self._client.get_available_sources(target_remote=False) + + # Use a fallback list of sources + except ValueError: + # Try to get software version from device + if self.device_info: + sw_version = self.device_info.get("sw_version") + if not sw_version: + sw_version = self._software_status.software_version + + _LOGGER.warning( + "The API is outdated compared to the device software version %s and %s. Using fallback sources", + MOZART_API_VERSION, + sw_version, + ) + sources = FALLBACK_SOURCES + + # Save all of the relevant enabled sources, both the ID and the friendly name for displaying in a dict. + self._audio_sources = { + source.id: source.name + for source in cast(list[Source], sources.items) + if source.is_enabled + and source.id + and source.name + and source.id not in HIDDEN_SOURCE_IDS + } + + # Video sources from remote menu + menu_items = await self._client.get_remote_menu() + + for key in menu_items: + menu_item = menu_items[key] + + if not menu_item.available: + continue + + # TV SOURCES + if ( + menu_item.content is not None + and menu_item.content.categories + and len(menu_item.content.categories) > 0 + and "music" not in menu_item.content.categories + and menu_item.label + and menu_item.label != "TV" + ): + self._video_sources[key] = menu_item.label + + # Combine the source dicts + self._sources = self._audio_sources | self._video_sources + + # HASS won't necessarily be running the first time this method is run + if self.hass.is_running: + self.async_write_ha_state() + + async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + """Update _playback_metadata and related.""" + self._playback_metadata = data + + # Update current artwork. + self._media_image = get_highest_resolution_artwork(self._playback_metadata) + + self.async_write_ha_state() + + async def _update_playback_error(self, data: PlaybackError) -> None: + """Show playback error.""" + _LOGGER.error(data.error) + + async def _update_playback_progress(self, data: PlaybackProgress) -> None: + """Update _playback_progress and last update.""" + self._playback_progress = data + self._attr_media_position_updated_at = utcnow() + + self.async_write_ha_state() + + async def _update_playback_state(self, data: RenderingState) -> None: + """Update _playback_state and related.""" + self._playback_state = data + + # Update entity state based on the playback state. + if self._playback_state.value: + self._state = self._playback_state.value + + self.async_write_ha_state() + + async def _update_source_change(self, data: Source) -> None: + """Update _source_change and related.""" + self._source_change = data + + # Check if source is line-in or optical and progress should be updated + if self._source_change.id in (SOURCE_ENUM.lineIn, SOURCE_ENUM.spdif): + self._playback_progress = PlaybackProgress(progress=0) + + async def _update_volume(self, data: VolumeState) -> None: + """Update _volume.""" + self._volume = data + + self.async_write_ha_state() + + @property + def state(self) -> MediaPlayerState: + """Return the current state of the media player.""" + return BANG_OLUFSEN_STATES[self._state] + + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if self._volume.level and self._volume.level.level: + return float(self._volume.level.level / 100) + return None + + @property + def is_volume_muted(self) -> bool | None: + """Boolean if volume is currently muted.""" + if self._volume.muted and self._volume.muted.muted: + return self._volume.muted.muted + return None + + @property + def media_content_type(self) -> str: + """Return the current media type.""" + # Hard to determine content type + if self.source == SOURCE_ENUM.uriStreamer: + return MediaType.URL + return MediaType.MUSIC + + @property + def media_duration(self) -> int | None: + """Return the total duration of the current track in seconds.""" + return self._playback_metadata.total_duration_seconds + + @property + def media_position(self) -> int | None: + """Return the current playback progress.""" + return self._playback_progress.progress + + @property + def media_image_url(self) -> str | None: + """Return URL of the currently playing music.""" + if self._media_image: + return self._media_image.url + return None + + @property + def media_image_remotely_accessible(self) -> bool: + """Return whether or not the image of the current media is available outside the local network.""" + return not self._media_image.has_local_image + + @property + def media_title(self) -> str | None: + """Return the currently playing title.""" + return self._playback_metadata.title + + @property + def media_album_name(self) -> str | None: + """Return the currently playing album name.""" + return self._playback_metadata.album_name + + @property + def media_album_artist(self) -> str | None: + """Return the currently playing artist name.""" + return self._playback_metadata.artist_name + + @property + def media_track(self) -> int | None: + """Return the currently playing track.""" + return self._playback_metadata.track + + @property + def media_channel(self) -> str | None: + """Return the currently playing channel.""" + return self._playback_metadata.organization + + @property + def source(self) -> str | None: + """Return the current audio source.""" + + # Try to fix some of the source_change chromecast weirdness. + if hasattr(self._playback_metadata, "title"): + # source_change is chromecast but line in is selected. + if self._playback_metadata.title == SOURCE_ENUM.lineIn: + return SOURCE_ENUM.lineIn + + # source_change is chromecast but bluetooth is selected. + if self._playback_metadata.title == SOURCE_ENUM.bluetooth: + return SOURCE_ENUM.bluetooth + + # source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket, + # And the source has not changed. + if self._source_change.id in ( + SOURCE_ENUM.bluetooth, + SOURCE_ENUM.lineIn, + SOURCE_ENUM.spdif, + ): + return SOURCE_ENUM.chromeCast + + # source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork + # So i assume that it is bluetooth and not chromecast + if ( + hasattr(self._playback_metadata, "art") + and self._playback_metadata.art is not None + ): + if ( + len(self._playback_metadata.art) == 0 + and self._source_change.name == SOURCE_ENUM.bluetooth + ): + return SOURCE_ENUM.bluetooth + + return self._source_change.name + + async def async_turn_off(self) -> None: + """Set the device to "networkStandby".""" + await self._client.post_standby() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self._client.set_current_volume_level( + volume_level=VolumeLevel(level=int(volume * 100)) + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute media player.""" + await self._client.set_volume_mute(volume_mute=VolumeMute(muted=mute)) + + async def async_media_play_pause(self) -> None: + """Toggle play/pause media player.""" + if self.state == MediaPlayerState.PLAYING: + await self.async_media_pause() + elif self.state in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE): + await self.async_media_play() + + async def async_media_pause(self) -> None: + """Pause media player.""" + await self._client.post_playback_command(command="pause") + + async def async_media_play(self) -> None: + """Play media player.""" + await self._client.post_playback_command(command="play") + + async def async_media_stop(self) -> None: + """Pause media player.""" + await self._client.post_playback_command(command="stop") + + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self._client.post_playback_command(command="skip") + + async def async_media_seek(self, position: float) -> None: + """Seek to position in ms.""" + if self.source == SOURCE_ENUM.deezer: + await self._client.seek_to_position(position_ms=int(position * 1000)) + # Try to prevent the playback progress from bouncing in the UI. + self._attr_media_position_updated_at = utcnow() + self._playback_progress = PlaybackProgress(progress=int(position)) + + self.async_write_ha_state() + else: + _LOGGER.error("Seeking is currently only supported when using Deezer") + + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self._client.post_playback_command(command="prev") + + async def async_clear_playlist(self) -> None: + """Clear the current playback queue.""" + await self._client.post_clear_queue() + + async def async_select_source(self, source: str) -> None: + """Select an input source.""" + if source not in self._sources.values(): + _LOGGER.error( + "Invalid source: %s. Valid sources are: %s", + source, + list(self._sources.values()), + ) + return + + # pylint: disable=consider-using-dict-items + key = [x for x in self._sources if self._sources[x] == source][0] + + # Check for source type + if source in self._audio_sources.values(): + # Audio + await self._client.set_active_source(source_id=key) + else: + # Video + await self._client.post_remote_trigger(id=key) + + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play from: netradio station id, URI, favourite or Deezer.""" + + # Convert audio/mpeg, audio/aac etc. to MediaType.MUSIC + if media_type.startswith("audio/"): + media_type = MediaType.MUSIC + + if media_type not in VALID_MEDIA_TYPES: + _LOGGER.error( + "%s is an invalid type. Valid values are: %s", + media_type, + VALID_MEDIA_TYPES, + ) + return + + if media_source.is_media_source_id(media_id): + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + + media_id = async_process_play_media_url(self.hass, sourced_media.url) + + # Remove playlist extension as it is unsupported. + if media_id.endswith(".m3u"): + media_id = media_id.replace(".m3u", "") + + if media_type in (MediaType.URL, MediaType.MUSIC): + await self._client.post_uri_source(uri=Uri(location=media_id)) + + # The "provider" media_type may not be suitable for overlay all the time. + # Use it for now. + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.TTS: + await self._client.post_overlay_play( + overlay_play_request=OverlayPlayRequest( + uri=Uri(location=media_id), + ) + ) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.RADIO: + await self._client.run_provided_scene( + scene_properties=SceneProperties( + action_list=[ + Action( + type="radio", + radio_station_id=media_id, + ) + ] + ) + ) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.FAVOURITE: + await self._client.activate_preset(id=int(media_id)) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.DEEZER: + try: + if media_id == "flow": + deezer_id = None + + if "id" in kwargs[ATTR_MEDIA_EXTRA]: + deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"] + + # Play Deezer flow. + await self._client.start_deezer_flow( + user_flow=UserFlow(user_id=deezer_id) + ) + + # Play a Deezer playlist or album. + elif any(match in media_id for match in ("playlist", "album")): + start_from = 0 + if "start_from" in kwargs[ATTR_MEDIA_EXTRA]: + start_from = kwargs[ATTR_MEDIA_EXTRA]["start_from"] + + await self._client.add_to_queue( + play_queue_item=PlayQueueItem( + provider=PlayQueueItemType(value="deezer"), + start_now_from_position=start_from, + type="playlist", + uri=media_id, + ) + ) + + # Play a Deezer track. + else: + await self._client.add_to_queue( + play_queue_item=PlayQueueItem( + provider=PlayQueueItemType(value="deezer"), + start_now_from_position=0, + type="track", + uri=media_id, + ) + ) + + except ApiException as error: + _LOGGER.error(json.loads(error.body)["message"]) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the WebSocket media browsing helper.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json new file mode 100644 index 00000000000..3cebfb891bc --- /dev/null +++ b/homeassistant/components/bang_olufsen/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "error": { + "api_exception": "[%key:common::config_flow::error::cannot_connect%]", + "client_connector_error": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_error": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ip": "Invalid IPv4 address" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "model": "[%key:common::generic::model%]" + }, + "description": "Manually configure your Bang & Olufsen device." + }, + "zeroconf_confirm": { + "title": "Setup Bang & Olufsen device", + "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." + } + } + } +} diff --git a/homeassistant/components/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py new file mode 100644 index 00000000000..617eb4b1df6 --- /dev/null +++ b/homeassistant/components/bang_olufsen/util.py @@ -0,0 +1,21 @@ +"""Various utilities for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN + + +def get_device(hass: HomeAssistant | None, unique_id: str) -> DeviceEntry | None: + """Get the device.""" + if not isinstance(hass, HomeAssistant): + return None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, unique_id)}) + assert device + + return device diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py new file mode 100644 index 00000000000..fd378a40bd3 --- /dev/null +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -0,0 +1,182 @@ +"""Update coordinator and WebSocket listener(s) for the Bang & Olufsen integration.""" + +from __future__ import annotations + +import logging + +from mozart_api.models import ( + PlaybackContentMetadata, + PlaybackError, + PlaybackProgress, + RenderingState, + SoftwareUpdateState, + Source, + VolumeState, + WebsocketNotificationTag, +) +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + BANG_OLUFSEN_WEBSOCKET_EVENT, + CONNECTION_STATUS, + WEBSOCKET_NOTIFICATION, +) +from .entity import BangOlufsenBase +from .util import get_device + +_LOGGER = logging.getLogger(__name__) + + +class BangOlufsenWebsocket(BangOlufsenBase): + """The WebSocket listeners.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, client: MozartClient + ) -> None: + """Initialize the WebSocket listeners.""" + + BangOlufsenBase.__init__(self, entry, client) + + self.hass = hass + self._device = get_device(hass, self._unique_id) + + # WebSocket callbacks + self._client.get_notification_notifications(self.on_notification_notification) + self._client.get_on_connection_lost(self.on_connection_lost) + self._client.get_on_connection(self.on_connection) + self._client.get_playback_error_notifications( + self.on_playback_error_notification + ) + self._client.get_playback_metadata_notifications( + self.on_playback_metadata_notification + ) + self._client.get_playback_progress_notifications( + self.on_playback_progress_notification + ) + self._client.get_playback_state_notifications( + self.on_playback_state_notification + ) + self._client.get_software_update_state_notifications( + self.on_software_update_state + ) + self._client.get_source_change_notifications(self.on_source_change_notification) + self._client.get_volume_notifications(self.on_volume_notification) + + # Used for firing events and debugging + self._client.get_all_notifications_raw(self.on_all_notifications_raw) + + def _update_connection_status(self) -> None: + """Update all entities of the connection status.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._client.websocket_connected, + ) + + def on_connection(self) -> None: + """Handle WebSocket connection made.""" + _LOGGER.debug("Connected to the %s notification channel", self._name) + self._update_connection_status() + + def on_connection_lost(self) -> None: + """Handle WebSocket connection lost.""" + _LOGGER.error("Lost connection to the %s", self._name) + self._update_connection_status() + + def on_notification_notification( + self, notification: WebsocketNotificationTag + ) -> None: + """Send notification dispatch.""" + if notification.value: + if WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}", + ) + + def on_playback_error_notification(self, notification: PlaybackError) -> None: + """Send playback_error dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}", + notification, + ) + + def on_playback_metadata_notification( + self, notification: PlaybackContentMetadata + ) -> None: + """Send playback_metadata dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}", + notification, + ) + + def on_playback_progress_notification(self, notification: PlaybackProgress) -> None: + """Send playback_progress dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}", + notification, + ) + + def on_playback_state_notification(self, notification: RenderingState) -> None: + """Send playback_state dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}", + notification, + ) + + def on_source_change_notification(self, notification: Source) -> None: + """Send source_change dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}", + notification, + ) + + def on_volume_notification(self, notification: VolumeState) -> None: + """Send volume dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}", + notification, + ) + + async def on_software_update_state(self, notification: SoftwareUpdateState) -> None: + """Check device sw version.""" + software_status = await self._client.get_softwareupdate_status() + + # Update the HA device if the sw version does not match + if not self._device: + self._device = get_device(self.hass, self._unique_id) + + assert self._device + + if software_status.software_version != self._device.sw_version: + device_registry = dr.async_get(self.hass) + + device_registry.async_update_device( + device_id=self._device.id, + sw_version=software_status.software_version, + ) + + def on_all_notifications_raw(self, notification: dict) -> None: + """Receive all notifications.""" + if not self._device: + self._device = get_device(self.hass, self._unique_id) + + assert self._device + + # Add the device_id and serial_number to the notification + notification["device_id"] = self._device.id + notification["serial_number"] = int(self._unique_id) + + _LOGGER.debug("%s", notification) + self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification) diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json new file mode 100644 index 00000000000..5bd1c338921 --- /dev/null +++ b/homeassistant/components/binary_sensor/icons.json @@ -0,0 +1,178 @@ +{ + "entity_component": { + "_": { + "default": "mdi:radiobox-blank", + "state": { + "on": "mdi:checkbox-marked-circle" + } + }, + "battery": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-outline" + } + }, + "battery_charging": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-charging" + } + }, + "carbon_monoxide": { + "default": "mdi:smoke-detector", + "state": { + "on": "mdi:smoke-detector-alert" + } + }, + "cold": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:snowflake" + } + }, + "connectivity": { + "default": "mdi:close-network-outline", + "state": { + "on": "mdi:check-network-outline" + } + }, + "door": { + "default": "mdi:door-closed", + "state": { + "on": "mdi:door-open" + } + }, + "garage_door": { + "default": "mdi:garage", + "state": { + "on": "mdi:garage-open" + } + }, + "gas": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "heat": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:fire" + } + }, + "light": { + "default": "mdi:brightness-5", + "state": { + "on": "mdi:brightness-7" + } + }, + "lock": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock-open" + } + }, + "moisture": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, + "motion": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } + }, + "moving": { + "default": "mdi:arrow-right", + "state": { + "on": "mdi:octagon" + } + }, + "occupancy": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "opening": { + "default": "mdi:square", + "state": { + "on": "mdi:square-outline" + } + }, + "plug": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "power": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "presence": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "problem": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "running": { + "default": "mdi:stop", + "state": { + "on": "mdi:play" + } + }, + "safety": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "smoke": { + "default": "mdi:smoke-detector-variant", + "state": { + "on": "mdi:smoke-detector-variant-alert" + } + }, + "sound": { + "default": "mdi:music-note-off", + "state": { + "on": "mdi:music-note" + } + }, + "tamper": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "update": { + "default": "mdi:package", + "state": { + "on": "mdi:package-up" + } + }, + "vibration": { + "default": "mdi:crop-portrait", + "state": { + "on": "mdi:vibrate" + } + }, + "window": { + "default": "mdi:window-closed", + "state": { + "on": "mdi:window-open" + } + } + } +} diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index e4ac8985ebd..1350f1f29a2 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -53,8 +53,13 @@ async def async_setup_entry( class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity): """Representation of a BleBox climate feature (saunaBox).""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self): diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index d83c2686563..50c7fad516a 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -107,7 +107,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -129,9 +128,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -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] diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 8e0750d1373..80a6ceb50e0 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -65,6 +65,7 @@ class BlinkSyncModuleHA( name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, serial_number=sync.serial, + sw_version=sync.version, ) self._update_attr() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 4d05aea88a5..838020c98c6 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -79,6 +79,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, serial_number=camera.serial, + sw_version=camera.version, name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, @@ -125,7 +126,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() - await self.coordinator.api.refresh() self.async_write_ha_state() def camera_image( diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 4326c6cb86c..aaacbb9390c 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -9,46 +9,17 @@ from blinkpy.auth import Auth, LoginError, TokenRefreshFailed from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PIN, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaFlowFormStep, - SchemaOptionsFlowHandler, -) -from .const import DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN +from .const import DEVICE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) -SIMPLE_OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): selector.NumberSelector( - selector.NumberSelectorConfig( - mode=selector.NumberSelectorMode.BOX, - unit_of_measurement="seconds", - ), - ), - } -) - - -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(next_step="simple_options"), - "simple_options": SchemaFlowFormStep(SIMPLE_OPTIONS_SCHEMA), -} - async def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" @@ -78,14 +49,6 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the blink flow.""" self.auth: Auth | None = None - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler: - """Get options flow for this handler.""" - return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 6e9d912f332..445a469b141 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.5"] + "requirements": ["blinkpy==0.22.6"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 74db76c421e..ea31d1b29ab 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -10,11 +10,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,9 +31,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, - translation_key="wifi_rssi", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, + translation_key="wifi_strength", + icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index a875fb3e343..09bbba4c226 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -39,8 +39,8 @@ }, "entity": { "sensor": { - "wifi_rssi": { - "name": "Wi-Fi RSSI" + "wifi_strength": { + "name": "Wi-Fi signal strength" } }, "binary_sensor": { diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py index 5b069cacdb3..59e224b0b6b 100644 --- a/homeassistant/components/bloomsky/__init__.py +++ b/homeassistant/components/bloomsky/__init__.py @@ -16,7 +16,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] DOMAIN = "bloomsky" diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 0dfa67f097d..604f251bfeb 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -16,7 +16,7 @@ from bluecurrent_api.exceptions import ( 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 ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -42,9 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await connector.connect(api_token) - except InvalidApiToken: - LOGGER.error("Invalid Api token") - return False + except InvalidApiToken as err: + raise ConfigEntryAuthFailed("Invalid API token.") from err except BlueCurrentException as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py index 32a6c177b49..68a30fcdf7f 100644 --- a/homeassistant/components/blue_current/config_flow.py +++ b/homeassistant/components/blue_current/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Blue Current integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bluecurrent_api import Client @@ -25,6 +26,7 @@ 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 @@ -51,11 +53,31 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: - await self.async_set_unique_id(customer_id) - self._abort_if_unique_id_configured() + 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) - 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/manifest.json b/homeassistant/components/blue_current/manifest.json index bff8a057f08..cadaac30d68 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", - "issue_tracker": "https://github.com/bluecurrent/ha-bluecurrent/issues", "requirements": ["bluecurrent-api==1.0.6"] } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 10c114e5f1c..293d0cd6ab7 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -18,7 +18,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with the api key for {email}." } }, "entity": { diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 63a1c1b45f0..33fb87cc578 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -90,17 +90,17 @@ class Blueprint: @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 @@ class BlueprintInputs: 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) @@ -212,7 +212,7 @@ class DomainBlueprints: 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_dict(self.blueprint_folder / blueprint_path) @@ -262,7 +262,7 @@ class DomainBlueprints: 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( @@ -337,7 +337,7 @@ class DomainBlueprints: return exists async def async_add_blueprint( - self, blueprint: Blueprint, blueprint_path: str, allow_override=False + self, blueprint: Blueprint, blueprint_path: str, allow_override: bool = False ) -> bool: """Add a blueprint.""" overrides_existing = await self.hass.async_add_executor_job( @@ -359,7 +359,7 @@ class DomainBlueprints: 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 c8271cc700d..fd3aa967336 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -25,7 +25,7 @@ from .const import ( ) -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 3c7cc3769c8..1989f0f563c 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -18,7 +18,7 @@ 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,7 +76,7 @@ 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 diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 0e2a26381d2..a0a61c14e8a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,10 +16,10 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.4.0", - "bluetooth-adapters==0.16.2", + "bluetooth-adapters==0.17.0", "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", - "dbus-fast==2.21.0", - "habluetooth==2.1.0" + "dbus-fast==2.21.1", + "habluetooth==2.4.0" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 827006fe19d..453ab996abc 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -237,10 +237,12 @@ class BluetoothMatcherIndexBase(Generic[_T]): def match(self, service_info: BluetoothServiceInfoBleak) -> list[_T]: """Check for a match.""" matches = [] - if service_info.name and len(service_info.name) >= LOCAL_NAME_MIN_MATCH_LENGTH: - for matcher in self.local_name.get( - service_info.name[:LOCAL_NAME_MIN_MATCH_LENGTH], [] - ): + if (name := service_info.name) and ( + local_name_matchers := self.local_name.get( + name[:LOCAL_NAME_MIN_MATCH_LENGTH] + ) + ): + for matcher in local_name_matchers: if ble_device_matches(matcher, service_info): matches.append(matcher) @@ -351,11 +353,6 @@ def _local_name_to_index_key(local_name: str) -> str: if they try to setup a matcher that will is overly broad as would match too many devices and cause a performance hit. """ - if len(local_name) < LOCAL_NAME_MIN_MATCH_LENGTH: - raise ValueError( - "Local name matchers must be at least " - f"{LOCAL_NAME_MIN_MATCH_LENGTH} characters long ({local_name})" - ) match_part = local_name[:LOCAL_NAME_MIN_MATCH_LENGTH] if "*" in match_part or "[" in match_part: raise ValueError( @@ -377,35 +374,29 @@ def ble_device_matches( if matcher.get(CONNECTABLE, True) and not service_info.connectable: return False - advertisement_data = service_info.advertisement if ( service_uuid := matcher.get(SERVICE_UUID) - ) and service_uuid not in advertisement_data.service_uuids: + ) and service_uuid not in service_info.service_uuids: return False if ( service_data_uuid := matcher.get(SERVICE_DATA_UUID) - ) and service_data_uuid not in advertisement_data.service_data: + ) and service_data_uuid not in service_info.service_data: return False - if manfacturer_id := matcher.get(MANUFACTURER_ID): - if manfacturer_id not in advertisement_data.manufacturer_data: + if manufacturer_id := matcher.get(MANUFACTURER_ID): + if manufacturer_id not in service_info.manufacturer_data: return False + if manufacturer_data_start := matcher.get(MANUFACTURER_DATA_START): - manufacturer_data_start_bytes = bytearray(manufacturer_data_start) - if not any( - manufacturer_data.startswith(manufacturer_data_start_bytes) - for manufacturer_data in advertisement_data.manufacturer_data.values() + if not service_info.manufacturer_data[manufacturer_id].startswith( + bytes(manufacturer_data_start) ): return False - if (local_name := matcher.get(LOCAL_NAME)) and ( - (device_name := advertisement_data.local_name or service_info.device.name) - is None - or not _memorized_fnmatch( - device_name, - local_name, - ) + if (local_name := matcher.get(LOCAL_NAME)) and not _memorized_fnmatch( + service_info.name, + local_name, ): return False diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index da8e6781bfa..26b485127f2 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -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 .const import DOMAIN @@ -105,7 +105,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) - hass = self.hass for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue @@ -114,13 +113,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token - new_data = {**entry.data, **updates} - changed = new_data != dict(entry.data) - if changed: - hass.config_entries.async_update_entry(entry, data=new_data) - entry_id = entry.entry_id - hass.async_create_task(hass.config_entries.async_reload(entry_id)) - raise AbortFlow("already_configured") + return self.async_update_reload_and_abort( + entry, data={**entry.data, **updates}, reason="already_configured" + ) self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} await self._async_try_automatic_configure() diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 03a5f444579..2c54ad8f3dd 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod from asyncio import Lock, TimeoutError as AsyncIOTimeoutError -from datetime import datetime, timedelta +from datetime import datetime import logging from aiohttp import ClientError @@ -27,8 +27,8 @@ from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) -_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) -_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60) +_FALLBACK_SCAN_INTERVAL = 10 +_BPUP_ALIVE_SCAN_INTERVAL = 60 class BondEntity(Entity): diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 465c4b8966b..403e0ae01e6 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -72,6 +72,14 @@ class BondFan(BondEntity, FanEntity): 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 @@ class BondFan(BondEntity, FanEntity): 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.""" diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py new file mode 100644 index 00000000000..aec3cd53c94 --- /dev/null +++ b/homeassistant/components/bring/__init__.py @@ -0,0 +1,75 @@ +"""The Bring! integration.""" +from __future__ import annotations + +import logging + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import ( + BringAuthException, + BringParseException, + BringRequestException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import BringDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Bring! from a config entry.""" + + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = async_get_clientsession(hass) + bring = Bring(email, password, sessionAsync=session) + + try: + await bring.loginAsync() + await bring.loadListsAsync() + except BringRequestException as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for email '{email}'" + ) from e + except BringAuthException as e: + _LOGGER.error( + "Authentication failed for '%s', check your email and password", + email, + ) + raise ConfigEntryError( + f"Authentication failed for '{email}', check your email and password" + ) from e + except BringParseException as e: + _LOGGER.error( + "Failed to parse request '%s', check your email and password", + email, + ) + raise ConfigEntryNotReady( + "Failed to parse response request from server, try again later" + ) from e + + coordinator = BringDataUpdateCoordinator(hass, bring) + 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/bring/config_flow.py b/homeassistant/components/bring/config_flow.py new file mode 100644 index 00000000000..122e71feea6 --- /dev/null +++ b/homeassistant/components/bring/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Bring! integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import BringAuthException, BringRequestException +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.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bring!.""" + + 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: + session = async_get_clientsession(self.hass) + bring = Bring( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD], sessionAsync=session + ) + + try: + await bring.loginAsync() + await bring.loadListsAsync() + except BringRequestException: + errors["base"] = "cannot_connect" + except BringAuthException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(bring.uuid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py new file mode 100644 index 00000000000..64a6ec67f85 --- /dev/null +++ b/homeassistant/components/bring/const.py @@ -0,0 +1,3 @@ +"""Constants for the Bring! integration.""" + +DOMAIN = "bring" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py new file mode 100644 index 00000000000..eb28f24e085 --- /dev/null +++ b/homeassistant/components/bring/coordinator.py @@ -0,0 +1,62 @@ +"""DataUpdateCoordinator for the Bring! integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import BringParseException, BringRequestException +from python_bring_api.types import BringItemsResponse, BringList + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BringData(BringList): + """Coordinator data class.""" + + items: list[BringItemsResponse] + + +class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): + """A Bring Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, bring: Bring) -> None: + """Initialize the Bring data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + ) + self.bring = bring + + async def _async_update_data(self) -> dict[str, BringData]: + try: + lists_response = await self.bring.loadListsAsync() + except BringRequestException as e: + raise UpdateFailed("Unable to connect and retrieve data from bring") from e + except BringParseException as e: + raise UpdateFailed("Unable to parse response from bring") from e + + list_dict = {} + for lst in lists_response["lists"]: + try: + items = await self.bring.getItemsAsync(lst["listUuid"]) + except BringRequestException as e: + raise UpdateFailed( + "Unable to connect and retrieve data from bring" + ) from e + except BringParseException as e: + raise UpdateFailed("Unable to parse response from bring") from e + lst["items"] = items["purchase"] + list_dict[lst["listUuid"]] = lst + + return list_dict diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json new file mode 100644 index 00000000000..e7d23bfc3df --- /dev/null +++ b/homeassistant/components/bring/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bring", + "name": "Bring!", + "codeowners": ["@miaucl", "@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bring", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["python-bring-api==3.0.0"] +} diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json new file mode 100644 index 00000000000..de3677bf5f1 --- /dev/null +++ b/homeassistant/components/bring/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": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py new file mode 100644 index 00000000000..14279c894af --- /dev/null +++ b/homeassistant/components/bring/todo.py @@ -0,0 +1,166 @@ +"""Todo platform for the Bring! integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_bring_api.exceptions import BringRequestException + +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.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BringData, BringDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: 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( + BringTodoListEntity( + coordinator, + bring_list=bring_list, + unique_id=unique_id, + ) + for bring_list in coordinator.data.values() + ) + + +class BringTodoListEntity( + CoordinatorEntity[BringDataUpdateCoordinator], TodoListEntity +): + """A To-do List representation of the Bring! Shopping List.""" + + _attr_icon = "mdi:cart" + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringData, + unique_id: str, + ) -> None: + """Initialize BringTodoListEntity.""" + super().__init__(coordinator) + self._list_uuid = bring_list["listUuid"] + self._attr_name = bring_list["name"] + self._attr_unique_id = f"{unique_id}_{self._list_uuid}" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo items.""" + return [ + TodoItem( + uid=item["name"], + summary=item["name"], + description=item["specification"] or "", + status=TodoItemStatus.NEEDS_ACTION, + ) + for item in self.bring_list["items"] + ] + + @property + def bring_list(self) -> BringData: + """Return the bring list.""" + return self.coordinator.data[self._list_uuid] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + try: + await self.coordinator.bring.saveItemAsync( + self.bring_list["listUuid"], item.summary, item.description or "" + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to save todo item for bring") from e + + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list. + + Bring has an internal 'recent' list which we want to use instead of a todo list + status, therefore completed todo list items will directly be deleted + + This results in following behaviour: + + - Completed items will move to the "completed" section in home assistant todo + list and get deleted in bring, which will remove them from the home + assistant todo list completely after a short delay + - Bring items do not have unique identifiers and are using the + name/summery/title. Therefore the name is not to be changed! Should a name + be changed anyway, a new item will be created instead and no update for + this item is performed and on the next cloud pull update, it will get + cleared + """ + + bring_list = self.bring_list + + if TYPE_CHECKING: + assert item.uid + + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.bring.removeItemAsync( + bring_list["listUuid"], + item.uid, + ) + + elif item.summary == item.uid: + try: + await self.coordinator.bring.updateItemAsync( + bring_list["listUuid"], + item.uid, + item.description or "", + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to update todo item for bring") from e + else: + try: + await self.coordinator.bring.removeItemAsync( + bring_list["listUuid"], + item.uid, + ) + await self.coordinator.bring.saveItemAsync( + bring_list["listUuid"], + item.summary, + item.description or "", + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to replace todo item for bring") from e + + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item from the To-do list.""" + for uid in uids: + try: + await self.coordinator.bring.removeItemAsync( + self.bring_list["listUuid"], uid + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to delete todo item for bring") from e + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index 6937d6bb0da..dd37d270f9e 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -35,9 +35,14 @@ class BroadlinkThermostat(ClimateEntity, BroadlinkEntity): _attr_has_entity_name = True _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: BroadlinkDevice) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 27ac97a27dc..32fee44de99 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -62,7 +62,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): +class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Brother data from the printer.""" def __init__(self, hass: HomeAssistant, brother: Brother) -> None: diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 609d5ab6e83..511701cb538 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -73,12 +73,16 @@ class BSBLANClimate( _attr_name = None # Determine preset modes _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_preset_modes = PRESET_MODES # Determine hvac modes _attr_hvac_modes = HVAC_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 59d52c3ae00..3f58fbe364c 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.16"] + "requirements": ["python-bsblan==0.5.18"] } diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 566609b998b..0031f09bb81 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate from bthome_ble.parser import EncryptionScheme @@ -19,6 +20,7 @@ from homeassistant.helpers.device_registry import ( DeviceRegistry, async_get, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( BTHOME_BLE_EVENT, @@ -30,7 +32,7 @@ from .const import ( ) from .coordinator import BTHomePassiveBluetoothProcessorCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -47,7 +49,7 @@ def process_service_info( coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - discovered_device_classes = coordinator.discovered_device_classes + discovered_event_classes = coordinator.discovered_event_classes if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: hass.config_entries.async_update_entry( entry, @@ -67,28 +69,35 @@ def process_service_info( sw_version=sensor_device_info.sw_version, hw_version=sensor_device_info.hw_version, ) + # event_class may be postfixed with a number, ie 'button_2' + # but if there is only one button then it will be 'button' event_class = event.device_key.key event_type = event.event_type - if event_class not in discovered_device_classes: - discovered_device_classes.add(event_class) + ble_event = BTHomeBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + + if event_class not in discovered_event_classes: + discovered_event_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_event_classes)}, + ) + async_dispatcher_send( + hass, format_discovered_event_class(address), event_class, ble_event ) - hass.bus.async_fire( - BTHOME_BLE_EVENT, - dict( - BTHomeBleEvent( - device_id=device.id, - address=address, - event_class=event_class, # ie 'button' - event_type=event_type, # ie 'press' - event_properties=event.event_properties, - ) - ), + hass.bus.async_fire(BTHOME_BLE_EVENT, cast(dict, ble_event)) + async_dispatcher_send( + hass, + format_event_dispatcher_name(address, event_class), + ble_event, ) # If payload is encrypted and the bindkey is not verified then we need to reauth @@ -98,6 +107,16 @@ def process_service_info( return update +def format_event_dispatcher_name(address: str, event_class: str) -> str: + """Format an event dispatcher name.""" + return f"{DOMAIN}_event_{address}_{event_class}" + + +def format_discovered_event_class(address: str) -> str: + """Format a discovered event class.""" + return f"{DOMAIN}_discovered_event_class_{address}" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BTHome Bluetooth from a config entry.""" address = entry.unique_id @@ -120,9 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, data, service_info, device_registry ), device_data=data, - discovered_device_classes=set( - entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) - ), + discovered_event_classes=set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])), connectable=False, entry=entry, ) diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index a728efdf05a..41440cb435f 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -75,7 +75,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - bindkey = user_input["bindkey"] + bindkey: str = user_input["bindkey"] if len(bindkey) != 32: errors["bindkey"] = "expected_32_characters" @@ -173,23 +173,15 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): # Otherwise there wasn't actually encryption so abort return self.async_abort(reason="reauth_successful") - def _async_get_or_create_entry(self, bindkey=None): - data = {} + def _async_get_or_create_entry(self, bindkey: str | None = None) -> FlowResult: + data: dict[str, Any] = {} if bindkey: data["bindkey"] = bindkey if entry_id := self.context.get("entry_id"): entry = self.hass.config_entries.async_get_entry(entry_id) assert entry is not None - - self.hass.config_entries.async_update_entry(entry, data=data) - - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=data) return self.async_create_entry( title=self.context["title_placeholders"]["name"], diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index bb743be7c7f..837ad58b7c2 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -30,13 +30,13 @@ class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordi mode: BluetoothScanningMode, update_method: Callable[[BluetoothServiceInfoBleak], Any], device_data: BTHomeBluetoothDeviceData, - discovered_device_classes: set[str], + discovered_event_classes: set[str], entry: ConfigEntry, connectable: bool = False, ) -> None: """Initialize the BTHome Bluetooth Passive Update Processor Coordinator.""" super().__init__(hass, logger, address, mode, update_method, connectable) - self.discovered_device_classes = discovered_device_classes + self.discovered_event_classes = discovered_event_classes self.device_data = device_data self.entry = entry diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index a81c30eee85..834b08ad39d 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -66,7 +66,7 @@ async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate trigger config.""" - return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( + return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return] config ) @@ -87,6 +87,9 @@ async def async_get_triggers( None, ) assert bthome_config_entry is not None + event_classes: list[str] = bthome_config_entry.data.get( + CONF_DISCOVERED_EVENT_CLASSES, [] + ) return [ { # Required fields of TRIGGER_BASE_SCHEMA @@ -97,10 +100,15 @@ async def async_get_triggers( CONF_TYPE: event_class, CONF_SUBTYPE: event_type, } - for event_class in bthome_config_entry.data.get( - CONF_DISCOVERED_EVENT_CLASSES, [] + for event_class in event_classes + for event_type in TRIGGERS_BY_EVENT_CLASS.get( + event_class.split("_")[0], + # If the device has multiple buttons they will have + # event classes like button_1 button_2, button_3, etc + # but if there is only one button then it will be + # button without a number postfix. + (), ) - for event_type in TRIGGERS_BY_EVENT_CLASS.get(event_class, []) ] diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py new file mode 100644 index 00000000000..39ad66d1d13 --- /dev/null +++ b/homeassistant/components/bthome/event.py @@ -0,0 +1,133 @@ +"""Support for bthome event entities.""" +from __future__ import annotations + +from dataclasses import replace + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import format_discovered_event_class, format_event_dispatcher_name +from .const import ( + DOMAIN, + EVENT_CLASS_BUTTON, + EVENT_CLASS_DIMMER, + EVENT_PROPERTIES, + EVENT_TYPE, + BTHomeBleEvent, +) +from .coordinator import BTHomePassiveBluetoothProcessorCoordinator + +DESCRIPTIONS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: EventEntityDescription( + key=EVENT_CLASS_BUTTON, + translation_key="button", + event_types=[ + "press", + "double_press", + "triple_press", + "long_press", + "long_double_press", + "long_triple_press", + ], + device_class=EventDeviceClass.BUTTON, + ), + EVENT_CLASS_DIMMER: EventEntityDescription( + key=EVENT_CLASS_DIMMER, + translation_key="dimmer", + event_types=["rotate_left", "rotate_right"], + ), +} + + +class BTHomeEventEntity(EventEntity): + """Representation of a BTHome event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + address: str, + event_class: str, + event: BTHomeBleEvent | None, + ) -> None: + """Initialise a BTHome event entity.""" + self._update_signal = format_event_dispatcher_name(address, event_class) + # event_class is something like "button" or "dimmer" + # and it maybe postfixed with "_1", "_2", "_3", etc + # If there is only one button then it will be "button" + base_event_class, _, postfix = event_class.partition("_") + base_description = DESCRIPTIONS_BY_EVENT_CLASS[base_event_class] + self.entity_description = replace(base_description, key=event_class) + postfix_name = f" {postfix}" if postfix else "" + self._attr_name = f"{base_event_class.title()}{postfix_name}" + # Matches logic in PassiveBluetoothProcessorEntity + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + self._attr_unique_id = f"{address}-{event_class}" + # If the event is provided then we can set the initial state + # since the event itself is likely what triggered the creation + # of this entity. We have to do this at creation time since + # entities are created dynamically and would otherwise miss + # the initial state. + if event: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._update_signal, + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: BTHomeBleEvent) -> None: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up BTHome event.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + address = coordinator.address + ent_reg = er.async_get(hass) + async_add_entities( + # Matches logic in PassiveBluetoothProcessorEntity + BTHomeEventEntity(address_event_class[0], address_event_class[2], None) + for ent_reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + if ent_reg_entry.domain == "event" + and (address_event_class := ent_reg_entry.unique_id.partition("-")) + ) + + @callback + def _async_discovered_event_class(event_class: str, event: BTHomeBleEvent) -> None: + """Handle a newly discovered event class with or without a postfix.""" + async_add_entities([BTHomeEventEntity(address, event_class, event)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + format_discovered_event_class(address), + _async_discovered_event_class, + ) + ) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index be64f01966f..2a7cf84f16b 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.3.1"] + "requirements": ["bthome-ble==3.5.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 10ba292d20c..17f8f6c7a3c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -278,7 +278,6 @@ SENSOR_DESCRIPTIONS = { ): SensorEntityDescription( key=str(BTHomeSensorDeviceClass.TIMESTAMP), device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, ), # UV index (-) ( @@ -316,7 +315,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.VOLUME}_{Units.VOLUME_LITERS}", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.LITERS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), # Volume (mL) ( @@ -326,7 +325,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.VOLUME}_{Units.VOLUME_MILLILITERS}", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.MILLILITERS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), # Volume Flow Rate (m3/hour) ( @@ -337,6 +336,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), + # Volume Storage (L) + ( + BTHomeExtendedSensorDeviceClass.VOLUME_STORAGE, + Units.VOLUME_LITERS, + ): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.VOLUME_STORAGE}_{Units.VOLUME_LITERS}", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), # Water (L) ( BTHomeSensorDeviceClass.WATER, diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 39ba3baa3fd..50c5c7bada6 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -44,5 +44,33 @@ "button": "Button \"{subtype}\"", "dimmer": "Dimmer \"{subtype}\"" } + }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "double_press": "Double press", + "triple_press": "Triple press", + "long_press": "Long press", + "long_double_press": "Long double press", + "long_triple_press": "Long triple press" + } + } + } + }, + "dimmer": { + "state_attributes": { + "event_type": { + "state": { + "rotate_left": "Rotate left", + "rotate_right": "Rotate right" + } + } + } + } + } } } diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 358348a8077..0acc5b63339 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,7 +1,7 @@ """Component to pressing a button as platforms.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta from enum import StrEnum import logging from typing import TYPE_CHECKING, final @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -95,7 +96,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ _attr_should_poll = False _attr_device_class: ButtonDeviceClass | None _attr_state: None = None - __last_pressed: datetime | None = None + __last_pressed_isoformat: str | None = None def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class. @@ -113,13 +114,19 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ return self.entity_description.device_class return None - @property + @cached_property @final def state(self) -> str | None: """Return the entity state.""" - if self.__last_pressed is None: - return None - return self.__last_pressed.isoformat() + return self.__last_pressed_isoformat + + def __set_state(self, state: str | None) -> None: + """Set the entity state.""" + try: # noqa: SIM105 suppress is much slower + del self.state + except AttributeError: + pass + self.__last_pressed_isoformat = state @final async def _async_press_action(self) -> None: @@ -127,7 +134,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ Should not be overridden, handle setting last press timestamp. """ - self.__last_pressed = dt_util.utcnow() + self.__set_state(dt_util.utcnow().isoformat()) self.async_write_ha_state() await self.async_press() @@ -135,8 +142,8 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ """Call when the button is added to hass.""" await super().async_internal_added_to_hass() state = await self.async_get_last_state() - if state is not None and state.state is not None: - self.__last_pressed = dt_util.parse_datetime(state.state) + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__set_state(state.state) def press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/button/icons.json b/homeassistant/components/button/icons.json new file mode 100644 index 00000000000..71956124d7f --- /dev/null +++ b/homeassistant/components/button/icons.json @@ -0,0 +1,19 @@ +{ + "entity_component": { + "_": { + "default": "mdi:button-pointer" + }, + "restart": { + "default": "mdi:restart" + }, + "identify": { + "default": "mdi:crosshairs-question" + }, + "update": { + "default": "mdi:package-up" + } + }, + "services": { + "press": "mdi:gesture-tap-button" + } +} diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 41e13b798b6..bef0e2fc09f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,4 +1,4 @@ -"""Support for Google Calendar event device sensors.""" +"""Support for Calendar event device sensors.""" from __future__ import annotations from collections.abc import Callable, Iterable diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json new file mode 100644 index 00000000000..e4e526fe75c --- /dev/null +++ b/homeassistant/components/calendar/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar", + "state": { + "on": "mdi:calendar-check", + "off": "mdi:calendar-blank" + } + } + }, + "services": { + "create_event": "mdi:calendar-plus", + "get_events": "mdi:calendar-month", + "list_events": "mdi:calendar-month" + } +} diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ce75f064d47..5a78728697b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -726,17 +726,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera state attributes.""" attrs = {"access_token": self.access_tokens[-1]} - if self.model: - attrs["model_name"] = self.model + if model := self.model: + attrs["model_name"] = model - if self.brand: - attrs["brand"] = self.brand + if brand := self.brand: + attrs["brand"] = brand - if self.motion_detection_enabled: - attrs["motion_detection"] = self.motion_detection_enabled + if motion_detection_enabled := self.motion_detection_enabled: + attrs["motion_detection"] = motion_detection_enabled - if self.frontend_stream_type: - attrs["frontend_stream_type"] = self.frontend_stream_type + if frontend_stream_type := self.frontend_stream_type: + attrs["frontend_stream_type"] = frontend_stream_type return attrs diff --git a/homeassistant/components/camera/icons.json b/homeassistant/components/camera/icons.json new file mode 100644 index 00000000000..37e71c80a67 --- /dev/null +++ b/homeassistant/components/camera/icons.json @@ -0,0 +1,19 @@ +{ + "entity_component": { + "_": { + "default": "mdi:video", + "state": { + "off": "mdi:video-off" + } + } + }, + "services": { + "disable_motion_detection": "mdi:motion-sensor-off", + "enable_motion_detection": "mdi:motion-sensor", + "play_stream": "mdi:play", + "record": "mdi:record-rec", + "snapshot": "mdi:camera", + "turn_off": "mdi:video-off", + "turn_on": "mdi:video" + } +} diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index dcb321d5ebb..e41e43c3a3c 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -98,6 +98,6 @@ class TurboJPEGSingleton: TurboJPEGSingleton.__instance = TurboJPEG() except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Error loading libturbojpeg; Cameras may impact HomeKit performance" + "Error loading libturbojpeg; Camera snapshot performance will be sub-optimal" ) TurboJPEGSingleton.__instance = False diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index e8e38a6e72b..730757de8b4 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,4 +1,15 @@ """Consts for Cast integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pychromecast.controllers.homeassistant import HomeAssistantController + +from homeassistant.helpers.dispatcher import SignalType + +if TYPE_CHECKING: + from .helpers import ChromecastInfo + DOMAIN = "cast" @@ -14,14 +25,16 @@ CAST_BROWSER_KEY = "cast_browser" # Dispatcher signal fired with a ChromecastInfo every time we discover a new # Chromecast or receive it through configuration -SIGNAL_CAST_DISCOVERED = "cast_discovered" +SIGNAL_CAST_DISCOVERED: SignalType[ChromecastInfo] = SignalType("cast_discovered") # Dispatcher signal fired with a ChromecastInfo every time a Chromecast is # removed -SIGNAL_CAST_REMOVED = "cast_removed" +SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed") # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. -SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" +SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ + HomeAssistantController, str, str, str | None +] = SignalType("cast_show_view") CONF_IGNORE_CEC = "ignore_cec" CONF_KNOWN_HOSTS = "known_hosts" diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 5035b3c6620..ae049fefef6 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.8"], + "requirements": ["PyChromecast==13.1.0"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 30896d12299..1f90f317fe0 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -64,8 +64,11 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 391bb3ef8f3..d46cecc7edb 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -14,8 +14,8 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load the saved entities.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - async def _async_finish_startup(_): + async def _async_finish_startup(_: HomeAssistant) -> None: await coordinator.async_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index ed294cab981..b3ceb95d301 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -35,7 +35,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _test_connection( self, user_input: Mapping[str, Any], - ): + ) -> bool: """Test connection to the server and try to get the certificate.""" try: await get_cert_expiry_timestamp( diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py index 6a125758f70..abb0b4ca727 100644 --- a/homeassistant/components/cert_expiry/coordinator.py +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT @@ -16,11 +17,11 @@ _LOGGER = logging.getLogger(__name__) class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): """Class to manage fetching Cert Expiry data from single endpoint.""" - def __init__(self, hass, host, port): + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize global Cert Expiry data updater.""" self.host = host self.port = port - self.cert_error = None + self.cert_error: ValidationFailure | None = None self.is_cert_valid = False display_port = f":{port}" if port != DEFAULT_PORT else "" diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 0817025c703..cde9364214e 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,7 +1,10 @@ """Helper functions for the Cert Expiry platform.""" +import asyncio +import datetime from functools import cache import socket import ssl +from typing import Any from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -16,36 +19,43 @@ from .errors import ( @cache -def _get_default_ssl_context(): +def _get_default_ssl_context() -> ssl.SSLContext: """Return the default SSL context.""" return ssl.create_default_context() -def get_cert( +async def async_get_cert( + hass: HomeAssistant, host: str, port: int, -): +) -> dict[str, Any]: """Get the certificate for the host and port combination.""" - ctx = _get_default_ssl_context() - address = (host, port) - with socket.create_connection(address, timeout=TIMEOUT) as sock, ctx.wrap_socket( - sock, server_hostname=address[0] - ) as ssock: - cert = ssock.getpeercert() - return cert + async with asyncio.timeout(TIMEOUT): + transport, _ = await hass.loop.create_connection( + asyncio.Protocol, + host, + port, + ssl=_get_default_ssl_context(), + happy_eyeballs_delay=0.25, + server_hostname=host, + ) + try: + return transport.get_extra_info("peercert") # type: ignore[no-any-return] + finally: + transport.close() async def get_cert_expiry_timestamp( hass: HomeAssistant, hostname: str, port: int, -): +) -> datetime.datetime: """Return the certificate's expiration timestamp.""" try: - cert = await hass.async_add_executor_job(get_cert, hostname, port) + cert = await async_get_cert(hass, hostname, port) except socket.gaierror as err: raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err - except socket.timeout as err: + except asyncio.TimeoutError as err: raise ConnectionTimeout( f"Connection timeout with server: {hostname}:{port}" ) from err diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 645642067e6..68e18fddc14 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import Any import voluptuous as vol @@ -12,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,12 +43,12 @@ async def async_setup_platform( """Set up certificate expiry sensor.""" @callback - def schedule_import(_): + def schedule_import(_: Event) -> None: """Schedule delayed import after HA is fully started.""" async_call_later(hass, 10, do_import) @callback - def do_import(_): + def do_import(_: datetime) -> None: """Process YAML import.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -80,7 +81,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): _attr_has_entity_name = True @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return additional sensor state attributes.""" return { "is_valid": self.coordinator.is_cert_valid, diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 6f4e1ead956..822919213c2 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -2,6 +2,7 @@ "domain": "cisco_webex_teams", "name": "Cisco Webex Teams", "codeowners": ["@fbradyirl"], + "disabled": "Integration library not compatible with Python 3.12", "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", "iot_class": "cloud_push", "loggers": ["webexteamssdk"], diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index c315765925f..43d98ad6bbd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import functools as ft import logging @@ -34,6 +35,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter @@ -152,8 +154,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service( + SERVICE_TURN_ON, + {}, + "async_turn_on", + [ClimateEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, + {}, + "async_turn_off", + [ClimateEntityFeature.TURN_OFF], + ) component.async_register_entity_service( SERVICE_SET_HVAC_MODE, {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, @@ -288,6 +300,101 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_target_temperature: float | None = None _attr_temperature_unit: str + __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) + # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False + # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. + _enable_turn_on_off_backwards_compatibility: bool = True + + def __getattribute__(self, __name: str) -> Any: + """Get attribute. + + Modify return of `supported_features` to + include `_mod_supported_features` if attribute is set. + """ + if __name != "supported_features": + return super().__getattribute__(__name) + + # Convert the supported features to ClimateEntityFeature. + # Remove this compatibility shim in 2025.1 or later. + _supported_features = super().__getattribute__(__name) + if type(_supported_features) is int: # noqa: E721 + new_features = ClimateEntityFeature(_supported_features) + self._report_deprecated_supported_features_values(new_features) + + # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to + # supported features and return it + return _supported_features | super().__getattribute__( + "_ClimateEntity__mod_supported_features" + ) + + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + + def _report_turn_on_off(feature: str, method: str) -> None: + """Log warning not implemented turn on/off feature.""" + report_issue = self._suggest_report_issue() + if feature.startswith("TURN"): + message = ( + "Entity %s (%s) does not set ClimateEntityFeature.%s" + " but implements the %s method. Please %s" + ) + else: + message = ( + "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly" + " supports the %s methods without setting the proper" + " ClimateEntityFeature. Please %s" + ) + _LOGGER.warning( + message, + self.entity_id, + type(self), + feature, + method, + report_issue, + ) + + # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented + # This should be removed in 2025.1. + if self._enable_turn_on_off_backwards_compatibility is False: + # Return if integration has migrated already + return + + if not self.supported_features & ClimateEntityFeature.TURN_OFF and ( + type(self).async_turn_off is not ClimateEntity.async_turn_off + or type(self).turn_off is not ClimateEntity.turn_off + ): + # turn_off implicitly supported by implementing turn_off method + _report_turn_on_off("TURN_OFF", "turn_off") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_OFF + ) + + if not self.supported_features & ClimateEntityFeature.TURN_ON and ( + type(self).async_turn_on is not ClimateEntity.async_turn_on + or type(self).turn_on is not ClimateEntity.turn_on + ): + # turn_on implicitly supported by implementing turn_on method + _report_turn_on_off("TURN_ON", "turn_on") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_ON + ) + + if (modes := self.hvac_modes) and len(modes) >= 2 and HVACMode.OFF in modes: + # turn_on/off implicitly supported by including more modes than 1 and one of these + # are HVACMode.OFF + _modes = [_mode for _mode in self.hvac_modes if _mode is not None] + _report_turn_on_off(", ".join(_modes or []), "turn_on/turn_off") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + @final @property def state(self) -> str | None: @@ -312,7 +419,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features_compat + supported_features = self.supported_features temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -345,7 +452,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features_compat + supported_features = self.supported_features temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -625,9 +732,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Turn auxiliary heater off.""" await self.hass.async_add_executor_job(self.turn_aux_heat_off) + def turn_on(self) -> None: + """Turn the entity on.""" + raise NotImplementedError + async def async_turn_on(self) -> None: """Turn the entity on.""" - if hasattr(self, "turn_on"): + # Forward to self.turn_on if it's been overridden. + if type(self).turn_on is not ClimateEntity.turn_on: await self.hass.async_add_executor_job(self.turn_on) return @@ -646,9 +758,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): await self.async_set_hvac_mode(mode) break + def turn_off(self) -> None: + """Turn the entity off.""" + raise NotImplementedError + async def async_turn_off(self) -> None: """Turn the entity off.""" - if hasattr(self, "turn_off"): + # Forward to self.turn_on if it's been overridden. + if type(self).turn_off is not ClimateEntity.turn_off: await self.hass.async_add_executor_job(self.turn_off) return @@ -661,19 +778,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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.""" diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 9c9153d9f63..c790b8596a9 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -163,6 +163,8 @@ class ClimateEntityFeature(IntFlag): PRESET_MODE = 16 SWING_MODE = 32 AUX_HEAT = 64 + TURN_OFF = 128 + TURN_ON = 256 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json new file mode 100644 index 00000000000..4317698b257 --- /dev/null +++ b/homeassistant/components/climate/icons.json @@ -0,0 +1,62 @@ +{ + "entity_component": { + "_": { + "default": "mdi:thermostat", + "state_attributes": { + "fan_mode": { + "default": "mdi:circle-medium", + "state": { + "diffuse": "mdi:weather-windy", + "focus": "mdi:target", + "high": "mdi:speedometer", + "low": "mdi:speedometer-slow", + "medium": "mdi:speedometer-medium", + "middle": "mdi:speedometer-medium", + "off": "mdi:fan-off", + "on": "mdi:fan" + } + }, + "hvac_action": { + "default": "mdi:circle-medium", + "state": { + "cooling": "mdi:snowflake", + "drying": "mdi:water-percent", + "fan": "mdi:fan", + "heating": "mdi:fire", + "idle": "mdi:clock-outline", + "off": "mdi:power", + "preheating": "mdi:heat-wave" + } + }, + "preset_mode": { + "default": "mdi:circle-medium", + "state": { + "activity": "mdi:motion-sensor", + "away": "mdi:account-arrow-right", + "boost": "mdi:rocket-launch", + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "home": "mdi:home", + "sleep": "mdi:bed" + } + }, + "swing_mode": { + "default": "mdi:circle-medium", + "state": { + "both": "mdi:arrow-all", + "horizontal": "mdi:arrow-left-right", + "off": "mdi:arrow-oscillating-off", + "on": "mdi:arrow-oscillating", + "vertical": "mdi:arrow-up-down" + } + } + } + } + }, + "services": { + "set_fan_mode": "mdi:fan", + "set_humidity": "mdi:water-percent", + "set_swing_mode": "mdi:arrow-oscillating", + "set_temperature": "mdi:thermometer" + } +} diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 405bb735b66..62952c5aae3 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -139,8 +139,12 @@ turn_on: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_ON turn_off: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_OFF diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 17c50778b2e..888e99e3a34 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -64,12 +65,14 @@ from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD -PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT, Platform.TTS] SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" -SIGNAL_CLOUD_CONNECTION_STATE = "CLOUD_CONNECTION_STATE" +SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType( + "CLOUD_CONNECTION_STATE" +) STARTUP_REPAIR_DELAY = 1 # 1 hour @@ -285,9 +288,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: loaded = False stt_platform_loaded = asyncio.Event() tts_platform_loaded = asyncio.Event() + stt_tts_entities_added = asyncio.Event() hass.data[DATA_PLATFORMS_SETUP] = { Platform.STT: stt_platform_loaded, Platform.TTS: tts_platform_loaded, + "stt_tts_entities_added": stt_tts_entities_added, } async def _on_start() -> None: @@ -327,6 +332,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: account_link.async_setup(hass) + # Load legacy tts platform for backwards compatibility. hass.async_create_task( async_load_platform( hass, @@ -374,8 +380,10 @@ def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: 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() + stt_tts_entities_added: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][ + "stt_tts_entities_added" + ] + stt_tts_entities_added.set() return True diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index 31e990cdb81..2c381dd0ac0 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -9,16 +9,23 @@ from homeassistant.components.assist_pipeline import ( ) from homeassistant.components.conversation import HOME_ASSISTANT_AGENT from homeassistant.components.stt import DOMAIN as STT_DOMAIN +from homeassistant.components.tts import DOMAIN as TTS_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 +from .const import ( + DATA_PLATFORMS_SETUP, + DOMAIN, + STT_ENTITY_UNIQUE_ID, + TTS_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. + # Wait for stt and tts platforms to set up and entities to be added + # 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 @@ -29,8 +36,11 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: 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. + new_tts_engine_id = entity_registry.async_get_entity_id( + TTS_DOMAIN, DOMAIN, TTS_ENTITY_UNIQUE_ID + ) + if new_stt_engine_id is None or new_tts_engine_id is None: + # If there's no cloud stt or tts entity, we can't create a cloud pipeline. return None def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: @@ -43,7 +53,7 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: if ( pipeline.conversation_engine == HOME_ASSISTANT_AGENT and pipeline.stt_engine in (DOMAIN, new_stt_engine_id) - and pipeline.tts_engine == DOMAIN + and pipeline.tts_engine in (DOMAIN, new_tts_engine_id) ): return pipeline.id return None @@ -52,7 +62,7 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: cloud_pipeline := await async_create_default_pipeline( hass, stt_engine_id=new_stt_engine_id, - tts_engine_id=DOMAIN, + tts_engine_id=new_tts_engine_id, pipeline_name="Home Assistant Cloud", ) ) is None: @@ -61,25 +71,34 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: return cloud_pipeline.id -async def async_migrate_cloud_pipeline_stt_engine( - hass: HomeAssistant, stt_engine_id: str +async def async_migrate_cloud_pipeline_engine( + hass: HomeAssistant, platform: Platform, 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. + """Migrate the pipeline engines in the cloud assist pipeline.""" + # Migrate existing pipelines with cloud stt or tts to use new cloud engine id. + # Added in 2024.02.0. Can be removed in 2025.02.0. + + # We need to make sure that both stt and tts are loaded before this migration. + # Assist pipeline will call default engine when setting up the store. + # Wait for the stt or tts platform loaded event here. + if platform == Platform.STT: + wait_for_platform = Platform.TTS + pipeline_attribute = "stt_engine" + elif platform == Platform.TTS: + wait_for_platform = Platform.STT + pipeline_attribute = "tts_engine" + else: + raise ValueError(f"Invalid platform {platform}") - # 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() + await platforms_setup[wait_for_platform].wait() # Make sure the pipeline store is loaded, needed because assist_pipeline # is an after dependency of cloud await async_setup_pipeline_store(hass) + kwargs: dict[str, str] = {pipeline_attribute: engine_id} 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) + if getattr(pipeline, pipeline_attribute) == DOMAIN: + await async_update_pipeline(hass, pipeline, **kwargs) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index cef3c5f0d42..8cf79d20c5d 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -29,6 +29,8 @@ from . import alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN from .prefs import CloudPreferences +_LOGGER = logging.getLogger(__name__) + VALID_REPAIR_TRANSLATION_KEYS = { "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", @@ -149,6 +151,7 @@ class CloudClient(Interface): async def cloud_connected(self) -> None: """When cloud is connected.""" + _LOGGER.debug("cloud_connected") is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_: Any) -> None: @@ -196,6 +199,9 @@ class CloudClient(Interface): async def cloud_disconnected(self) -> None: """When cloud disconnected.""" + _LOGGER.debug("cloud_disconnected") + if self._google_config: + self._google_config.async_disable_local_sdk() async def cloud_started(self) -> None: """When cloud is started.""" @@ -207,6 +213,8 @@ class CloudClient(Interface): """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) + if self._google_config: + self._google_config.async_deinitialize() self._google_config = None @callback diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index db964607923..97d2345f16b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,4 +1,10 @@ """Constants for the cloud component.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.dispatcher import SignalType + DOMAIN = "cloud" DATA_PLATFORMS_SETUP = "cloud_platforms_setup" REQUEST_TIMEOUT = 10 @@ -64,6 +70,7 @@ CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" MODE_PROD = "production" -DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" +DISPATCHER_REMOTE_UPDATE: SignalType[Any] = SignalType("cloud_remote_update") STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" +TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index c11ec47b2e5..42f25f43ae1 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( + CALLBACK_TYPE, CoreState, Event, HomeAssistant, @@ -144,6 +145,7 @@ class CloudGoogleConfig(AbstractConfig): self._prefs = prefs self._cloud = cloud self._sync_entities_lock = asyncio.Lock() + self._on_deinitialize: list[CALLBACK_TYPE] = [] @property def enabled(self) -> bool: @@ -209,9 +211,11 @@ class CloudGoogleConfig(AbstractConfig): async def async_initialize(self) -> None: """Perform async initialization of config.""" + _LOGGER.debug("async_initialize") await super().async_initialize() async def on_hass_started(hass: HomeAssistant) -> None: + _LOGGER.debug("async_initialize on_hass_started") if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: _LOGGER.info( "Start migration of Google Assistant settings from v%s to v%s", @@ -238,16 +242,19 @@ class CloudGoogleConfig(AbstractConfig): await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) - async_listen_entity_updates( - self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + self._on_deinitialize.append( + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) ) async def on_hass_start(hass: HomeAssistant) -> None: + _LOGGER.debug("async_initialize on_hass_start") if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) - start.async_at_start(self.hass, on_hass_start) - start.async_at_started(self.hass, on_hass_started) + self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) + self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) # Remove any stored user agent id that is not ours remove_agent_user_ids = [] @@ -255,18 +262,33 @@ class CloudGoogleConfig(AbstractConfig): if agent_user_id != self.agent_user_id: remove_agent_user_ids.append(agent_user_id) + if remove_agent_user_ids: + _LOGGER.debug("remove non cloud agent_user_ids: %s", remove_agent_user_ids) for agent_user_id in remove_agent_user_ids: await self.async_disconnect_agent_user(agent_user_id) - self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, + self._on_deinitialize.append( + self._prefs.async_listen_updates(self._async_prefs_updated) ) - self.hass.bus.async_listen( - dr.EVENT_DEVICE_REGISTRY_UPDATED, - self._handle_device_registry_updated, + self._on_deinitialize.append( + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) ) + self._on_deinitialize.append( + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + self._handle_device_registry_updated, + ) + ) + + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" @@ -365,6 +387,7 @@ class CloudGoogleConfig(AbstractConfig): async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" + _LOGGER.debug("_async_prefs_updated") if not self._cloud.is_logged_in: if self.is_reporting_state: self.async_disable_report_state() @@ -412,7 +435,7 @@ class CloudGoogleConfig(AbstractConfig): if ( not self.enabled or not self._cloud.is_logged_in - or self.hass.state != CoreState.running + or self.hass.state is not CoreState.running ): return @@ -435,7 +458,7 @@ class CloudGoogleConfig(AbstractConfig): if ( not self.enabled or not self._cloud.is_logged_in - or self.hass.state != CoreState.running + or self.hass.state is not CoreState.running ): return diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index f7337e1d771..d314aac2092 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.75.1"] + "requirements": ["hass-nabucasa==0.76.0"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 4cc02867347..af5f9213e4d 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -104,10 +104,18 @@ class CloudPreferences: @callback def async_listen_updates( self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]] - ) -> None: + ) -> Callable[[], None]: """Listen for updates to the preferences.""" + + @callback + def unsubscribe() -> None: + """Remove the listener.""" + self._listeners.remove(listener) + self._listeners.append(listener) + return unsubscribe + async def async_update( self, *, diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 56fb3c0f5c9..6f1e3c80bf7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -24,6 +24,17 @@ } }, "issues": { + "deprecated_voice": { + "title": "A deprecated voice was used", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::cloud::issues::deprecated_voice::title%]", + "description": "The '{deprecated_voice}' voice is deprecated and will be removed.\nPlease update your automations and scripts to replace the '{deprecated_voice}' with another voice like eg. '{replacement_voice}'." + } + } + } + }, "legacy_subscription": { "title": "Legacy subscription detected", "fix_flow": { diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index b652a36fa8a..3368f25f94a 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,6 +1,7 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations +import asyncio from collections.abc import AsyncIterable import logging @@ -19,12 +20,13 @@ from homeassistant.components.stt import ( SpeechToTextEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .assist_pipeline import async_migrate_cloud_pipeline_stt_engine +from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DOMAIN, STT_ENTITY_UNIQUE_ID +from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID _LOGGER = logging.getLogger(__name__) @@ -35,18 +37,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Home Assistant Cloud speech platform via config entry.""" + stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] + stt_platform_loaded.set() cloud: Cloud[CloudClient] = hass.data[DOMAIN] async_add_entities([CloudProviderEntity(cloud)]) class CloudProviderEntity(SpeechToTextEntity): - """NabuCasa speech API provider.""" + """Home Assistant Cloud 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.""" + """Initialize cloud Speech to text entity.""" self.cloud = cloud @property @@ -81,7 +85,9 @@ class CloudProviderEntity(SpeechToTextEntity): 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) + await async_migrate_cloud_pipeline_engine( + self.hass, platform=Platform.STT, engine_id=self.entity_id + ) async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index f8152243bf5..ba34ac7a9b0 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,6 +1,7 @@ """Support for the cloud for text-to-speech service.""" from __future__ import annotations +import asyncio import logging from typing import Any @@ -12,20 +13,27 @@ from homeassistant.components.tts import ( ATTR_AUDIO_OUTPUT, ATTR_VOICE, CONF_LANG, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TextToSpeechEntity, TtsAudioType, Voice, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DOMAIN +from .const import DATA_PLATFORMS_SETUP, DOMAIN, TTS_ENTITY_UNIQUE_ID from .prefs import CloudPreferences ATTR_GENDER = "gender" +DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"} SUPPORT_LANGUAGES = list(TTS_VOICES) _LOGGER = logging.getLogger(__name__) @@ -48,7 +56,7 @@ def validate_lang(value: dict[str, Any]) -> dict[str, Any]: PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( + TTS_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, vol.Optional(ATTR_GENDER): str, @@ -81,8 +89,97 @@ async def async_get_engine( return cloud_provider +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Home Assistant Cloud text-to-speech platform.""" + tts_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.TTS] + tts_platform_loaded.set() + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + async_add_entities([CloudTTSEntity(cloud)]) + + +class CloudTTSEntity(TextToSpeechEntity): + """Home Assistant Cloud text-to-speech entity.""" + + _attr_name = "Home Assistant Cloud" + _attr_unique_id = TTS_ENTITY_UNIQUE_ID + + def __init__(self, cloud: Cloud[CloudClient]) -> None: + """Initialize cloud text-to-speech entity.""" + self.cloud = cloud + self._language, self._gender = cloud.client.prefs.tts_default_voice + + async def _sync_prefs(self, prefs: CloudPreferences) -> None: + """Sync preferences.""" + self._language, self._gender = prefs.tts_default_voice + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._language + + @property + def default_options(self) -> dict[str, Any]: + """Return a dict include default options.""" + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotion.""" + return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + await async_migrate_cloud_pipeline_engine( + self.hass, platform=Platform.TTS, engine_id=self.entity_id + ) + self.async_on_remove( + self.cloud.client.prefs.async_listen_updates(self._sync_prefs) + ) + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + if not (voices := TTS_VOICES.get(language)): + return None + return [Voice(voice, voice) for voice in voices] + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS from Home Assistant Cloud.""" + original_voice: str | None = options.get(ATTR_VOICE) + voice = handle_deprecated_voice(self.hass, original_voice) + # Process TTS + try: + data = await self.cloud.voice.process_tts( + text=message, + language=language, + gender=options.get(ATTR_GENDER), + voice=voice, + output=options[ATTR_AUDIO_OUTPUT], + ) + except VoiceError as err: + _LOGGER.error("Voice error: %s", err) + return (None, None) + + return (str(options[ATTR_AUDIO_OUTPUT].value), data) + + class CloudProvider(Provider): - """NabuCasa Cloud speech API provider.""" + """Home Assistant Cloud speech API provider.""" def __init__( self, cloud: Cloud[CloudClient], language: str | None, gender: str | None @@ -136,14 +233,17 @@ class CloudProvider(Provider): async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: - """Load TTS from NabuCasa Cloud.""" + """Load TTS from Home Assistant Cloud.""" + original_voice: str | None = options.get(ATTR_VOICE) + assert self.hass is not None + voice = handle_deprecated_voice(self.hass, original_voice) # Process TTS try: data = await self.cloud.voice.process_tts( text=message, language=language, gender=options.get(ATTR_GENDER), - voice=options.get(ATTR_VOICE), + voice=voice, output=options[ATTR_AUDIO_OUTPUT], ) except VoiceError as err: @@ -151,3 +251,33 @@ class CloudProvider(Provider): return (None, None) return (str(options[ATTR_AUDIO_OUTPUT].value), data) + + +@callback +def handle_deprecated_voice( + hass: HomeAssistant, + original_voice: str | None, +) -> str | None: + """Handle deprecated voice.""" + voice = original_voice + if ( + original_voice + and voice + and (voice := DEPRECATED_VOICES.get(original_voice, original_voice)) + != original_voice + ): + async_create_issue( + hass, + DOMAIN, + f"deprecated_voice_{original_voice}", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + breaks_in_ha_version="2024.8.0", + translation_key="deprecated_voice", + translation_placeholders={ + "deprecated_voice": original_voice, + "replacement_voice": voice, + }, + ) + return voice diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index dfa1e25d7d8..a678868ee18 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -4,8 +4,11 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aioelectricitymaps import ElectricityMaps -from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken +from aioelectricitymaps import ( + ElectricityMaps, + ElectricityMapsError, + ElectricityMapsInvalidTokenError, +) import voluptuous as vol from homeassistant import config_entries @@ -146,22 +149,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await fetch_latest_carbon_intensity(self.hass, em, data) - except InvalidToken: + except ElectricityMapsInvalidTokenError: errors["base"] = "invalid_auth" except ElectricityMapsError: errors["base"] = "unknown" else: if self._reauth_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( 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", diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 115c976b465..b06bee38bc4 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -4,9 +4,12 @@ from __future__ import annotations from datetime import timedelta import logging -from aioelectricitymaps import ElectricityMaps -from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken -from aioelectricitymaps.models import CarbonIntensityResponse +from aioelectricitymaps import ( + CarbonIntensityResponse, + ElectricityMaps, + ElectricityMapsError, + ElectricityMapsInvalidTokenError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -43,7 +46,7 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): return await fetch_latest_carbon_intensity( self.hass, self.client, self.config_entry.data ) - except InvalidToken as err: + except ElectricityMapsInvalidTokenError as err: raise ConfigEntryAuthFailed from err except ElectricityMapsError as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 937b72a357c..f61fadaf88c 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -1,4 +1,6 @@ """Helper functions for the CO2 Signal integration.""" +from __future__ import annotations + from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/co2signal/icons.json b/homeassistant/components/co2signal/icons.json new file mode 100644 index 00000000000..e934fc49e41 --- /dev/null +++ b/homeassistant/components/co2signal/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "carbon_intensity": { + "default": "mdi:molecule-co2" + }, + "fossil_fuel_percentage": { + "default": "mdi:molecule-co2" + } + } + } +} diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index f91232c1a28..4f22ee68910 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.1.5"] + "requirements": ["aioelectricitymaps==0.3.1"] } diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 9f955e35ed8..bff17becede 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -67,7 +67,6 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): entity_description: CO2SensorEntityDescription _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - _attr_icon = "mdi:molecule-co2" _attr_state_class = SensorStateClass.MEASUREMENT def __init__( diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py index 68403b4803e..b588e0abef9 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -2,14 +2,15 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Any from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE -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/coautilities/__init__.py b/homeassistant/components/coautilities/__init__.py new file mode 100644 index 00000000000..f21006af29d --- /dev/null +++ b/homeassistant/components/coautilities/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: City of Austin Utilities.""" diff --git a/homeassistant/components/coautilities/manifest.json b/homeassistant/components/coautilities/manifest.json new file mode 100644 index 00000000000..be213d03587 --- /dev/null +++ b/homeassistant/components/coautilities/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "coautilities", + "name": "City of Austin Utilities", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index c51081196c9..06db68a2444 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -11,6 +11,7 @@ from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem BRIDGE_PLATFORMS = [ + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.SENSOR, diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py new file mode 100644 index 00000000000..5a879bc2d24 --- /dev/null +++ b/homeassistant/components/comelit/climate.py @@ -0,0 +1,206 @@ +"""Support for climates.""" +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, + UnitOfTemperature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +class ClimaMode(StrEnum): + """Serial Bridge clima modes.""" + + AUTO = "A" + OFF = "O" + LOWER = "L" + UPPER = "U" + + +class ClimaAction(StrEnum): + """Serial Bridge clima actions.""" + + OFF = "off" + ON = "on" + MANUAL = "man" + SET = "set" + AUTO = "auto" + + +API_STATUS: dict[str, dict[str, Any]] = { + ClimaMode.OFF: { + "action": "off", + "hvac_mode": HVACMode.OFF, + "hvac_action": HVACAction.OFF, + }, + ClimaMode.LOWER: { + "action": "lower", + "hvac_mode": HVACMode.COOL, + "hvac_action": HVACAction.COOLING, + }, + ClimaMode.UPPER: { + "action": "upper", + "hvac_mode": HVACMode.HEAT, + "hvac_action": HVACAction.HEATING, + }, +} + +MODE_TO_ACTION: dict[HVACMode, ClimaAction] = { + HVACMode.OFF: ClimaAction.OFF, + HVACMode.AUTO: ClimaAction.AUTO, + HVACMode.COOL: ClimaAction.MANUAL, + HVACMode.HEAT: ClimaAction.MANUAL, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit climates.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitClimateEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[CLIMATE].values() + ) + + +class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity): + """Climate device.""" + + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] + _attr_max_temp = 30 + _attr_min_temp = 5 + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_target_temperature_step = PRECISION_TENTHS + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init light entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, device.type) + + @property + def _clima(self) -> list[Any]: + """Return clima device data.""" + # CLIMATE has a 2 item tuple: + # - first for Clima + # - second for Humidifier + return self.coordinator.data[CLIMATE][self._device.index].val[0] + + @property + def _api_mode(self) -> str: + """Return device mode.""" + # Values from API: "O", "L", "U" + return self._clima[2] + + @property + def _api_active(self) -> bool: + "Return device active/idle." + return self._clima[1] + + @property + def _api_automatic(self) -> bool: + """Return device in automatic/manual mode.""" + return self._clima[3] == ClimaMode.AUTO + + @property + def target_temperature(self) -> float: + """Set target temperature.""" + return self._clima[4] / 10 + + @property + def current_temperature(self) -> float: + """Return current temperature.""" + return self._clima[0] / 10 + + @property + def hvac_mode(self) -> HVACMode | None: + """HVAC current mode.""" + + if self._api_mode == ClimaMode.OFF: + return HVACMode.OFF + + if self._api_automatic: + return HVACMode.AUTO + + if self._api_mode in API_STATUS: + return API_STATUS[self._api_mode]["hvac_mode"] + + return None + + @property + def hvac_action(self) -> HVACAction | None: + """HVAC current action.""" + + if self._api_mode == ClimaMode.OFF: + return HVACAction.OFF + + if not self._api_active: + return HVACAction.IDLE + + if self._api_mode in API_STATUS: + return API_STATUS[self._api_mode]["hvac_action"] + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ( + target_temp := kwargs.get(ATTR_TEMPERATURE) + ) is None or self.hvac_mode == HVACMode.OFF: + return + + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.MANUAL + ) + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.SET, target_temp + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode != HVACMode.OFF: + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.ON + ) + await self.coordinator.api.set_clima_status( + self._device.index, MODE_TO_ACTION[hvac_mode] + ) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8c47564b165..bbbb4efe7d6 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.7.3"] + "requirements": ["aiocomelit==0.8.3"] } diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index e1a051cea33..701391ab389 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -55,6 +55,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY from homeassistant.helpers.typing import ConfigType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN @@ -90,6 +91,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL ): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) COVER_SCHEMA = vol.Schema( @@ -105,6 +107,7 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) NOTIFY_SCHEMA = vol.Schema( @@ -129,6 +132,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) SWITCH_SCHEMA = vol.Schema( @@ -144,6 +148,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) COMBINED_SCHEMA = vol.Schema( @@ -200,7 +205,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 f559812207f..20b538fc4d7 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 ( @@ -24,7 +24,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -63,6 +66,7 @@ async def async_setup_platform( scan_interval: timedelta = binary_sensor_config.get( CONF_SCAN_INTERVAL, SCAN_INTERVAL ) + availability: Template | None = binary_sensor_config.get(CONF_AVAILABILITY) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -72,6 +76,7 @@ async def async_setup_platform( CONF_NAME: Template(name, hass), CONF_DEVICE_CLASS: device_class, CONF_ICON: icon, + CONF_AVAILABILITY: availability, } async_add_entities( @@ -115,7 +120,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): 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 +131,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): ), ) - 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 6b413712ed7..845de352d73 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 @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -50,6 +53,7 @@ async def async_setup_platform( trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), + CONF_AVAILABILITY: device_config.get(CONF_AVAILABILITY), } covers.append( @@ -147,7 +151,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): 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 +190,14 @@ class CommandCover(ManualTriggerEntity, CoverEntity): 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 99390e77357..c1d60b9d2fd 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations 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 @@ class CommandSensor(ManualTriggerSensorEntity): """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 @@ class CommandSensor(ManualTriggerSensorEntity): @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 @@ class CommandSensor(ManualTriggerSensorEntity): ), ) - 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 8d30de310ef..efeded194ce 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 @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -48,6 +51,7 @@ async def async_setup_platform( CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), CONF_ICON: device_config.get(CONF_ICON), + CONF_AVAILABILITY: device_config.get(CONF_AVAILABILITY), } value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) @@ -155,7 +159,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): 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 +201,11 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): 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/config/__init__.py b/homeassistant/components/config/__init__.py index 84a1c2eaa17..05e5d3b9a2d 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,9 +1,14 @@ """Component to configure Home Assistant via an API.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable, Coroutine from http import HTTPStatus import importlib import os +from typing import Any, Generic, TypeVar, cast +from aiohttp import web import voluptuous as vol from homeassistant.components import frontend @@ -16,6 +21,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ATTR_COMPONENT from homeassistant.util.file import write_utf8_file_atomic from homeassistant.util.yaml import dump, load_yaml +from homeassistant.util.yaml.loader import JSON_TYPE + +_DataT = TypeVar("_DataT", dict[str, dict[str, Any]], list[dict[str, Any]]) DOMAIN = "config" SECTIONS = ( @@ -42,7 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, "config", "config", "hass:cog", require_admin=True ) - async def setup_panel(panel_name): + async def setup_panel(panel_name: str) -> None: """Set up a panel.""" panel = importlib.import_module(f".{panel_name}", __name__) @@ -63,20 +71,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class BaseEditConfigView(HomeAssistantView): +class BaseEditConfigView(HomeAssistantView, Generic[_DataT]): """Configure a Group endpoint.""" def __init__( self, - component, - config_type, - path, - key_schema, - data_schema, + component: str, + config_type: str, + path: str, + key_schema: Callable[[Any], str], + data_schema: Callable[[dict[str, Any]], Any], *, - post_write_hook=None, - data_validator=None, - ): + post_write_hook: Callable[[str, str], Coroutine[Any, Any, None]] | None = None, + data_validator: Callable[ + [HomeAssistant, str, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any] | None], + ] + | None = None, + ) -> None: """Initialize a config view.""" self.url = f"/api/config/{component}/{config_type}/{{config_key}}" self.name = f"api:config:{component}:{config_type}" @@ -87,26 +99,36 @@ class BaseEditConfigView(HomeAssistantView): self.data_validator = data_validator self.mutation_lock = asyncio.Lock() - def _empty_config(self): + def _empty_config(self) -> _DataT: """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: _DataT, config_key: str + ) -> dict[str, Any] | None: """Get value.""" raise NotImplementedError - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: _DataT, + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" raise NotImplementedError - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: _DataT, config_key: str + ) -> dict[str, Any] | None: """Delete value.""" raise NotImplementedError @require_admin - async def get(self, request, config_key): + async def get(self, request: web.Request, config_key: str) -> web.Response: """Fetch device specific config.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self.mutation_lock: current = await self.read_config(hass) value = self._get_value(hass, current, config_key) @@ -117,7 +139,7 @@ class BaseEditConfigView(HomeAssistantView): return self.json(value) @require_admin - async def post(self, request, config_key): + async def post(self, request: web.Request, config_key: str) -> web.Response: """Validate config and return results.""" try: data = await request.json() @@ -129,7 +151,7 @@ class BaseEditConfigView(HomeAssistantView): except vol.Invalid as err: return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: # We just validate, we don't store that data because @@ -159,9 +181,9 @@ class BaseEditConfigView(HomeAssistantView): return self.json({"result": "ok"}) @require_admin - async def delete(self, request, config_key): + async def delete(self, request: web.Request, config_key: str) -> web.Response: """Remove an entry.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self.mutation_lock: current = await self.read_config(hass) value = self._get_value(hass, current, config_key) @@ -178,46 +200,64 @@ class BaseEditConfigView(HomeAssistantView): return self.json({"result": "ok"}) - async def read_config(self, hass): + async def read_config(self, hass: HomeAssistant) -> _DataT: """Read the config.""" current = await hass.async_add_executor_job(_read, hass.config.path(self.path)) if not current: current = self._empty_config() - return current + return cast(_DataT, current) -class EditKeyBasedConfigView(BaseEditConfigView): +class EditKeyBasedConfigView(BaseEditConfigView[dict[str, dict[str, Any]]]): """Configure a list of entries.""" - def _empty_config(self): + def _empty_config(self) -> dict[str, Any]: """Return an empty config.""" return {} - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str + ) -> dict[str, Any] | None: """Get value.""" return data.get(config_key) - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: dict[str, dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" data.setdefault(config_key, {}).update(new_value) - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str + ) -> dict[str, Any]: """Delete value.""" return data.pop(config_key) -class EditIdBasedConfigView(BaseEditConfigView): +class EditIdBasedConfigView(BaseEditConfigView[list[dict[str, Any]]]): """Configure key based config entries.""" - def _empty_config(self): + def _empty_config(self) -> list[Any]: """Return an empty config.""" return [] - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str + ) -> dict[str, Any] | None: """Get value.""" return next((val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" if (value := self._get_value(hass, data, config_key)) is None: value = {CONF_ID: config_key} @@ -225,7 +265,9 @@ class EditIdBasedConfigView(BaseEditConfigView): value.update(new_value) - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str + ) -> None: """Delete value.""" index = next( idx for idx, val in enumerate(data) if val.get(CONF_ID) == config_key @@ -233,7 +275,7 @@ class EditIdBasedConfigView(BaseEditConfigView): data.pop(index) -def _read(path): +def _read(path: str) -> JSON_TYPE | None: """Read YAML helper.""" if not os.path.isfile(path): return None @@ -241,7 +283,7 @@ def _read(path): return load_yaml(path) -def _write(path, data): +def _write(path: str, data: dict | list) -> None: """Write YAML helper.""" # Do it before opening file. If dump causes error it will now not # truncate the file. diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index d41c712dffb..c8dc7450183 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -1,14 +1,16 @@ """HTTP views to interact with the area registry.""" +from __future__ import annotations + from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.area_registry import async_get +from homeassistant.helpers.area_registry import AreaEntry, async_get -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Area Registry views.""" websocket_api.async_register_command(hass, websocket_list_areas) websocket_api.async_register_command(hass, websocket_create_area) @@ -36,6 +38,7 @@ def websocket_list_areas( { vol.Required("type"): "config/area_registry/create", vol.Optional("aliases"): list, + vol.Optional("icon"): str, vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), } @@ -95,6 +98,7 @@ def websocket_delete_area( vol.Required("type"): "config/area_registry/update", vol.Optional("aliases"): list, vol.Required("area_id"): str, + vol.Optional("icon"): vol.Any(str, None), vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), } @@ -126,11 +130,12 @@ def websocket_update_area( @callback -def _entry_dict(entry): +def _entry_dict(entry: AreaEntry) -> dict[str, Any]: """Convert entry to API format.""" return { - "aliases": entry.aliases, + "aliases": list(entry.aliases), "area_id": entry.id, + "icon": entry.icon, "name": entry.name, "picture": entry.picture, } diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 1699a4c8509..355dc739a9c 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -1,8 +1,11 @@ """Offer API to configure Home Assistant auth.""" +from __future__ import annotations + from typing import Any import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant @@ -17,7 +20,7 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command( hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST @@ -151,7 +154,7 @@ async def websocket_update( ) -def _user_info(user): +def _user_info(user: User) -> dict[str, Any]: """Format a user.""" ha_username = next( diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index d0606a748a9..c8b7e91f5a7 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -1,4 +1,6 @@ """Offer API to configure the Home Assistant auth provider.""" +from __future__ import annotations + from typing import Any import voluptuous as vol @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import Unauthorized -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command(hass, websocket_create) websocket_api.async_register_command(hass, websocket_delete) @@ -115,7 +117,7 @@ async def websocket_change_password( ) -> None: """Change current user password.""" if (user := connection.user) is None: - connection.send_error(msg["id"], "user_not_found", "User not found") + connection.send_error(msg["id"], "user_not_found", "User not found") # type: ignore[unreachable] return provider = auth_ha.async_get_provider(hass) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 72a493f8c1f..02131fe2169 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,7 @@ """Provide configuration end points for Automations.""" +from __future__ import annotations + +from typing import Any import uuid from homeassistant.components.automation.config import ( @@ -8,19 +11,19 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Automation config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads automations.""" - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) - if action != ACTION_DELETE: + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) return ent_reg = er.async_get(hass) @@ -49,7 +52,13 @@ async def async_setup(hass): class EditAutomationConfigView(EditIdBasedConfigView): """Edit automation config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" updated_value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 77e2548d424..b19c0101232 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,8 +1,9 @@ """Http views to control the config manager.""" from __future__ import annotations +from collections.abc import Callable from http import HTTPStatus -from typing import Any +from typing import Any, NoReturn from aiohttp import web import aiohttp.web_exceptions @@ -29,7 +30,7 @@ from homeassistant.loader import ( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) @@ -58,7 +59,7 @@ class ConfigManagerEntryIndexView(HomeAssistantView): url = "/api/config/config_entries/entry" name = "api:config:config_entries:entry" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """List available config entries.""" hass: HomeAssistant = request.app["hass"] domain = None @@ -76,12 +77,12 @@ class ConfigManagerEntryResourceView(HomeAssistantView): url = "/api/config/config_entries/entry/{entry_id}" name = "api:config:config_entries:entry:resource" - async def delete(self, request, entry_id): + async def delete(self, request: web.Request, entry_id: str) -> web.Response: """Delete a config entry.""" if not request["hass_user"].is_admin: raise Unauthorized(config_entry_id=entry_id, permission="remove") - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: result = await hass.config_entries.async_remove(entry_id) @@ -97,12 +98,12 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): url = "/api/config/config_entries/entry/{entry_id}/reload" name = "api:config:config_entries:entry:resource:reload" - async def post(self, request, entry_id): + async def post(self, request: web.Request, entry_id: str) -> web.Response: """Reload a config entry.""" if not request["hass_user"].is_admin: raise Unauthorized(config_entry_id=entry_id, permission="remove") - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] entry = hass.config_entries.async_get_entry(entry_id) if not entry: return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) @@ -116,7 +117,12 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): return self.json({"require_restart": not entry.state.recoverable}) -def _prepare_config_flow_result_json(result, prepare_result_json): +def _prepare_config_flow_result_json( + result: data_entry_flow.FlowResult, + prepare_result_json: Callable[ + [data_entry_flow.FlowResult], data_entry_flow.FlowResult + ], +) -> data_entry_flow.FlowResult: """Convert result to JSON.""" if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) @@ -134,14 +140,14 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): url = "/api/config/config_entries/flow" name = "api:config:config_entries:flow" - async def get(self, request): + async def get(self, request: web.Request) -> NoReturn: """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" try: return await super().post(request) @@ -151,7 +157,9 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): status=HTTPStatus.BAD_REQUEST, ) - def _prepare_result_json(self, result): + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) @@ -165,18 +173,20 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def get(self, request, /, flow_id): + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def post(self, request, flow_id): + async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) - def _prepare_result_json(self, result): + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) @@ -187,10 +197,10 @@ class ConfigManagerAvailableFlowView(HomeAssistantView): url = "/api/config/config_entries/flow_handlers" name = "api:config:config_entries:flow_handlers" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """List available flow handlers.""" - hass = request.app["hass"] - kwargs = {} + hass: HomeAssistant = request.app["hass"] + kwargs: dict[str, Any] = {} if "type" in request.query: kwargs["type_filter"] = request.query["type"] return self.json(await async_get_config_flows(hass, **kwargs)) @@ -205,7 +215,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request. handler in request is entry_id. @@ -222,14 +232,14 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def get(self, request, /, flow_id): + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def post(self, request, flow_id): + async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -506,7 +516,7 @@ async def async_matching_config_entries( if not type_filter: return [entry_json(entry) for entry in entries] - integrations = {} + integrations: dict[str, Integration] = {} # Fetch all the integrations so we can check their type domains = {entry.domain for entry in entries} for domain_key, integration_or_exc in ( @@ -521,35 +531,32 @@ async def async_matching_config_entries( # when only helpers are requested, also filter out entries # from unknown integrations. This prevent them from showing # up in the helpers UI. - entries = [ - entry + filter_is_not_helper = type_filter != ["helper"] + filter_set = set(type_filter) + return [ + entry_json(entry) for entry in entries - if (type_filter != ["helper"] and entry.domain not in integrations) - or ( - entry.domain in integrations - and integrations[entry.domain].integration_type in type_filter + # If the filter is not 'helper', we still include the integration + # even if its not returned from async_get_integrations for backwards + # compatibility. + if ( + (integration := integrations.get(entry.domain)) + and integration.integration_type in filter_set ) + or (filter_is_not_helper and entry.domain not in integrations) ] - return [entry_json(entry) for entry in entries] - @callback -def entry_json(entry: config_entries.ConfigEntry) -> dict: +def entry_json(entry: config_entries.ConfigEntry) -> dict[str, Any]: """Return JSON value of a config entry.""" - handler = config_entries.HANDLERS.get(entry.domain) - # work out if handler has support for options flow - supports_options = handler is not None and handler.async_supports_options_flow( - entry - ) - return { "entry_id": entry.entry_id, "domain": entry.domain, "title": entry.title, "source": entry.source, "state": entry.state.value, - "supports_options": supports_options, + "supports_options": entry.supports_options, "supports_remove_device": entry.supports_remove_device or False, "supports_unload": entry.supports_unload or False, "pref_disable_new_entities": entry.pref_disable_new_entities, diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 4c64028874d..e6eac5f6e8e 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,7 +1,9 @@ """Component to interact with Hassbian tools.""" +from __future__ import annotations from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.components import websocket_api @@ -13,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, unit_system -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Hassbian config.""" hass.http.register_view(CheckConfigView) websocket_api.async_register_command(hass, websocket_update_config) @@ -28,7 +30,7 @@ class CheckConfigView(HomeAssistantView): name = "api:config:core:check_config" @require_admin - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Validate configuration and return results.""" res = await check_config.async_check_ha_config_file(request.app["hass"]) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 74c15da2f00..dfa55b02c30 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -17,7 +17,7 @@ from homeassistant.helpers.device_registry import ( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Device Registry views.""" websocket_api.async_register_command(hass, websocket_list_devices) @@ -45,16 +45,16 @@ def websocket_list_devices( msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' f'"success":true,"result": [' - ) + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.json_repr for entry in registry.devices.values() if entry.json_repr is not None ) - + "]}" + + b"]}" ) connection.send_message(msg_json) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index a0e0d1877fa..f1c1fadc144 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -43,20 +43,23 @@ def websocket_list_entities( msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' '"success":true,"result": [' - ) + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.partial_json_repr for entry in registry.entities.values() if entry.partial_json_repr is not None ) - + "]}" + + b"]}" ) connection.send_message(msg_json) +_ENTITY_CATEGORIES_JSON = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) + + @websocket_api.websocket_command( {vol.Required("type"): "config/entity_registry/list_for_display"} ) @@ -69,20 +72,19 @@ def websocket_list_entities_for_display( """Handle list registry entries command.""" registry = er.async_get(hass) # Build start of response message - entity_categories = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) msg_json_prefix = ( f'{{"id":{msg["id"]},"type":"{websocket_api.const.TYPE_RESULT}","success":true,' - f'"result":{{"entity_categories":{entity_categories},"entities":[' - ) + f'"result":{{"entity_categories":{_ENTITY_CATEGORIES_JSON},"entities":[' + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.display_json_repr for entry in registry.entities.values() if entry.disabled_by is None and entry.display_json_repr is not None ) - + "]}}" + + b"]}}" ) connection.send_message(msg_json) diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index 037cd55d6a0..efbfd73db05 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -1,19 +1,22 @@ """Provide configuration end points for Scenes.""" +from __future__ import annotations + +from typing import Any import uuid from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA from homeassistant.config import SCENE_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Scene config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scenes.""" if action != ACTION_DELETE: await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -44,7 +47,13 @@ async def async_setup(hass): class EditSceneConfigView(EditIdBasedConfigView): """Edit scene config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" updated_value = {CONF_ID: config_key} # Iterate through some keys that we want to have ordered in the output diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index 73f89aaf509..aa8a2a52d83 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,4 +1,8 @@ """Provide configuration end points for scripts.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.script import DOMAIN from homeassistant.components.script.config import ( SCRIPT_ENTITY_SCHEMA, @@ -6,15 +10,16 @@ from homeassistant.components.script.config import ( ) from homeassistant.config import SCRIPT_CONFIG_PATH from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditKeyBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the script config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scripts.""" if action != ACTION_DELETE: await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -46,6 +51,12 @@ async def async_setup(hass): class EditScriptConfigView(EditKeyBasedConfigView): """Edit script config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: dict[str, dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" data[config_key] = new_value diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 29dd56c11ec..09b0e8e2310 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -8,7 +8,13 @@ import logging import re from typing import Any, Literal -from hassil.recognize import RecognizeResult +from aiohttp import web +from hassil.recognize import ( + MISSING_ENTITY, + RecognizeResult, + UnmatchedRangeEntity, + UnmatchedTextEntity, +) import voluptuous as vol from homeassistant import core @@ -25,7 +31,13 @@ from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .const import HOME_ASSISTANT_AGENT -from .default_agent import DefaultAgent, async_setup as async_setup_default_agent +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + DefaultAgent, + SentenceTriggerResult, + async_setup as async_setup_default_agent, +) __all__ = [ "DOMAIN", @@ -42,6 +54,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" ATTR_LANGUAGE = "language" ATTR_AGENT_ID = "agent_id" +ATTR_CONVERSATION_ID = "conversation_id" DOMAIN = "conversation" @@ -66,6 +79,7 @@ SERVICE_PROCESS_SCHEMA = vol.Schema( 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, } ) @@ -106,7 +120,7 @@ def async_set_agent( hass: core.HomeAssistant, config_entry: ConfigEntry, agent: AbstractConversationAgent, -): +) -> None: """Set the agent to handle the conversations.""" _get_agent_manager(hass).async_set_agent(config_entry.entry_id, agent) @@ -116,7 +130,7 @@ def async_set_agent( def async_unset_agent( hass: core.HomeAssistant, config_entry: ConfigEntry, -): +) -> None: """Set the agent to handle the conversations.""" _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) @@ -131,7 +145,7 @@ async def async_get_conversation_languages( all conversation agents. """ agent_manager = _get_agent_manager(hass) - languages = set() + languages: set[str] = set() agent_ids: Iterable[str] if agent_id is None: @@ -164,7 +178,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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), @@ -314,37 +328,70 @@ async def websocket_hass_agent_debug( ] # Return results for each sentence in the same order as the input. - connection.send_result( - msg["id"], - { - "results": [ - { - "intent": { - "name": result.intent.name, - }, - "slots": { # direct access to values - entity_key: entity.value - for entity_key, entity in result.entities.items() - }, - "details": { - entity_key: { - "name": entity.name, - "value": entity.value, - "text": entity.text, - } - for entity_key, entity in result.entities.items() - }, - "targets": { - state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, result) - }, + result_dicts: list[dict[str, Any] | None] = [] + for result in results: + result_dict: dict[str, Any] | None = None + if isinstance(result, SentenceTriggerResult): + result_dict = { + # Matched a user-defined sentence trigger. + # We can't provide the response here without executing the + # trigger. + "match": True, + "source": "trigger", + "sentence_template": result.sentence_template or "", + } + elif isinstance(result, RecognizeResult): + successful_match = not result.unmatched_entities + result_dict = { + # Name of the matching intent (or the closest) + "intent": { + "name": result.intent.name, + }, + # Slot values that would be received by the intent + "slots": { # direct access to values + entity_key: entity.text or entity.value + for entity_key, entity in result.entities.items() + }, + # Extra slot details, such as the originally matched text + "details": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in result.entities.items() + }, + # Entities/areas/etc. that would be targeted + "targets": {}, + # True if match was successful + "match": successful_match, + # Text of the sentence template that matched (or was closest) + "sentence_template": "", + # When match is incomplete, this will contain the best slot guesses + "unmatched_slots": _get_unmatched_slots(result), + } + + if successful_match: + result_dict["targets"] = { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets(hass, result) } - if result is not None - else None - for result in results - ] - }, - ) + + if result.intent_sentence is not None: + result_dict["sentence_template"] = result.intent_sentence.text + + # Inspect metadata to determine if this matched a custom sentence + if result.intent_metadata and result.intent_metadata.get( + METADATA_CUSTOM_SENTENCE + ): + result_dict["source"] = "custom" + result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE) + else: + result_dict["source"] = "builtin" + + result_dicts.append(result_dict) + + connection.send_result(msg["id"], {"results": result_dicts}) def _get_debug_targets( @@ -376,6 +423,16 @@ def _get_debug_targets( # HassGetState only state_names = set(cv.ensure_list(entities["state"].value)) + if ( + (name is None) + and (area_name is None) + and (not domains) + and (not device_classes) + and (not state_names) + ): + # Avoid "matching" all entities when there is no filter + return + states = intent.async_match_states( hass, name=name, @@ -390,6 +447,25 @@ def _get_debug_targets( yield state, is_matched +def _get_unmatched_slots( + result: RecognizeResult, +) -> dict[str, str | int]: + """Return a dict of unmatched text/range slot entities.""" + unmatched_slots: dict[str, str | int] = {} + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text == MISSING_ENTITY: + # Don't report since these are just missing context + # slots. + continue + + unmatched_slots[entity.name] = entity.text + elif isinstance(entity, UnmatchedRangeEntity): + unmatched_slots[entity.name] = entity.value + + return unmatched_slots + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" @@ -406,7 +482,7 @@ class ConversationProcessView(http.HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Send a request for processing.""" hass = request.app["hass"] diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index aae8f67e1d8..fb33d87e107 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1,4 +1,5 @@ """Standard conversation implementation for Home Assistant.""" + from __future__ import annotations import asyncio @@ -12,16 +13,15 @@ import re from typing import IO, Any from hassil.expression import Expression, ListReference, Sequence -from hassil.intents import ( - Intents, - ResponseType, - SlotList, - TextSlotList, - WildcardSlotList, +from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList +from hassil.recognize import ( + MISSING_ENTITY, + RecognizeResult, + UnmatchedTextEntity, + recognize_all, ) -from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict -from home_assistant_intents import get_domains_and_languages, get_intents +from home_assistant_intents import ErrorKey, get_intents, get_languages import yaml from homeassistant import core, setup @@ -29,7 +29,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_listen_entity_updates, async_should_expose, ) -from homeassistant.const import MATCH_ALL +from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -55,6 +55,8 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]] +METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" +METADATA_CUSTOM_FILE = "hass_custom_file" def json_load(fp: IO[str]) -> JsonObjectType: @@ -70,7 +72,7 @@ class LanguageIntents: intents_dict: dict[str, Any] intent_responses: dict[str, Any] error_responses: dict[str, Any] - loaded_components: set[str] + language_variant: str | None @dataclass(slots=True) @@ -81,6 +83,15 @@ class TriggerData: callback: TRIGGER_CALLBACK_TYPE +@dataclass(slots=True) +class SentenceTriggerResult: + """Result when matching a sentence trigger in an automation.""" + + sentence: str + sentence_template: str | None + matched_triggers: dict[int, RecognizeResult] + + def _get_language_variations(language: str) -> Iterable[str]: """Generate language codes with and without region.""" yield language @@ -138,9 +149,9 @@ class DefaultAgent(AbstractConversationAgent): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return get_domains_and_languages()["homeassistant"] + return get_languages() - async def async_initialize(self, config_intents): + async def async_initialize(self, config_intents: dict[str, Any] | None) -> None: """Initialize the default agent.""" if "intent" not in self.hass.config.components: await setup.async_setup_component(self.hass, "intent", {}) @@ -151,17 +162,17 @@ class DefaultAgent(AbstractConversationAgent): self.hass.bus.async_listen( ar.EVENT_AREA_REGISTRY_UPDATED, - self._async_handle_area_registry_changed, + self._async_handle_area_registry_changed, # type: ignore[arg-type] run_immediately=True, ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, - self._async_handle_entity_registry_changed, + self._async_handle_entity_registry_changed, # type: ignore[arg-type] run_immediately=True, ) self.hass.bus.async_listen( - core.EVENT_STATE_CHANGED, - self._async_handle_state_changed, + EVENT_STATE_CHANGED, + self._async_handle_state_changed, # type: ignore[arg-type] run_immediately=True, ) async_listen_entity_updates( @@ -170,15 +181,16 @@ class DefaultAgent(AbstractConversationAgent): async def async_recognize( self, user_input: ConversationInput - ) -> RecognizeResult | None: + ) -> RecognizeResult | SentenceTriggerResult | None: """Recognize intent from user input.""" + if trigger_result := await self._match_triggers(user_input.text): + return trigger_result + language = user_input.language or self.hass.config.language lang_intents = self._lang_intents.get(language) # Reload intents if missing or new components - if lang_intents is None or ( - lang_intents.loaded_components - self.hass.config.components - ): + if lang_intents is None: # Load intents in executor lang_intents = await self.async_get_or_load_intents(language) @@ -196,27 +208,77 @@ class DefaultAgent(AbstractConversationAgent): lang_intents, slot_lists, intent_context, + language, ) return result async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" - if trigger_result := await self._match_triggers(user_input.text): - return trigger_result - language = user_input.language or self.hass.config.language conversation_id = None # Not supported result = await self.async_recognize(user_input) + + # Check if a trigger matched + if isinstance(result, SentenceTriggerResult): + # Gather callback responses in parallel + trigger_responses = await asyncio.gather( + *( + self._trigger_sentences[trigger_id].callback( + result.sentence, trigger_result + ) + for trigger_id, trigger_result in result.matched_triggers.items() + ) + ) + + # Use last non-empty result as response. + # + # There may be multiple copies of a trigger running when editing in + # the UI, so it's critical that we filter out empty responses here. + response_text: str | None = None + for trigger_response in trigger_responses: + response_text = response_text or trigger_response + + # Convert to conversation result + response = intent.IntentResponse(language=language) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text or "Done") + + return ConversationResult(response=response) + + # Intent match or failure 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, intent.IntentResponseErrorCode.NO_INTENT_MATCH, - self._get_error_text(ResponseType.NO_INTENT, lang_intents), + self._get_error_text(ErrorKey.NO_INTENT, lang_intents), + 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) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), conversation_id, ) @@ -226,7 +288,8 @@ class DefaultAgent(AbstractConversationAgent): # Slot values to pass to the intent slots = { - entity.name: {"value": entity.value} for entity in result.entities_list + entity.name: {"value": entity.value, "text": entity.text or entity.value} + for entity in result.entities_list } try: @@ -240,12 +303,27 @@ class DefaultAgent(AbstractConversationAgent): language, assistant=DOMAIN, ) + except intent.NoStatesMatchedError as no_states_error: + # Intent was valid, but no entities matched the constraints. + error_response_type, error_response_args = _get_no_states_matched_response( + no_states_error + ) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) except intent.IntentHandleError: + # Intent was valid and entities matched constraints, but an error + # occurred during handling. _LOGGER.exception("Intent handling error") return _make_error_result( language, intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents), + self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), conversation_id, ) except intent.IntentUnexpectedError: @@ -253,7 +331,7 @@ class DefaultAgent(AbstractConversationAgent): return _make_error_result( language, intent.IntentResponseErrorCode.UNKNOWN, - self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents), + self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), conversation_id, ) @@ -283,6 +361,7 @@ class DefaultAgent(AbstractConversationAgent): 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 @@ -292,6 +371,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents.intents, slot_lists=slot_lists, intent_context=intent_context, + language=language, ): if "name" in result.entities: return result @@ -299,7 +379,54 @@ class DefaultAgent(AbstractConversationAgent): # 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 + best_num_unmatched_entities = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_unmatched_entities = 0 + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if maybe_result is None: + # First result + maybe_result = result + best_num_unmatched_entities = num_unmatched_entities + elif num_unmatched_entities < best_num_unmatched_entities: + # Fewer unmatched entities + maybe_result = result + best_num_unmatched_entities = num_unmatched_entities + elif num_unmatched_entities == best_num_unmatched_entities: + if (result.text_chunks_matched > maybe_result.text_chunks_matched) or ( + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and ("name" in result.unmatched_entities) # prefer entities + ): + # More literal text chunks matched, but prefer entities to areas, etc. + 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, @@ -351,9 +478,11 @@ class DefaultAgent(AbstractConversationAgent): for entity_name, entity_value in recognize_result.entities.items() }, # First matched or unmatched state - "state": template.TemplateState(self.hass, state1) - if state1 is not None - else None, + "state": ( + template.TemplateState(self.hass, state1) + if state1 is not None + else None + ), "query": { # Entity states that matched the query (e.g, "on") "matched": [ @@ -374,7 +503,7 @@ class DefaultAgent(AbstractConversationAgent): return speech - async def async_reload(self, language: str | None = None): + async def async_reload(self, language: str | None = None) -> None: """Clear cached intents for a language.""" if language is None: self._lang_intents.clear() @@ -383,7 +512,7 @@ class DefaultAgent(AbstractConversationAgent): self._lang_intents.pop(language, None) _LOGGER.debug("Cleared intents for language: %s", language) - async def async_prepare(self, language: str | None = None): + async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" if language is None: language = self.hass.config.language @@ -410,84 +539,101 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: intents_dict: dict[str, Any] = {} - loaded_components: set[str] = set() + language_variant: str | None = None else: intents_dict = lang_intents.intents_dict - loaded_components = lang_intents.loaded_components + language_variant = lang_intents.language_variant - # en-US, en_US, en, ... - language_variations = list(_get_language_variations(language)) + supported_langs = set(get_languages()) - # Check if any new components have been loaded - intents_changed = False - for component in hass_components: - if component in loaded_components: - continue + if not language_variant: + # Choose a language variant upfront and commit to it for custom + # sentences, etc. + all_language_variants = {lang.lower(): lang for lang in supported_langs} - # Don't check component again - loaded_components.add(component) - - # Check for intents for this component with the target language. - # Try en-US, en, etc. - for language_variation in language_variations: - component_intents = get_intents( - component, language_variation, json_load=json_load - ) - if component_intents: - # Merge sentences into existing dictionary - merge_dict(intents_dict, component_intents) - - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded intents component=%s, language=%s (%s)", - component, - language, - language_variation, - ) + # en-US, en_US, en, ... + for maybe_variant in _get_language_variations(language): + matching_variant = all_language_variants.get(maybe_variant.lower()) + if matching_variant: + language_variant = matching_variant break + if not language_variant: + _LOGGER.warning( + "Unable to find supported language variant for %s", language + ) + return None + + # Load intents for this language variant + lang_variant_intents = get_intents(language_variant, json_load=json_load) + + if lang_variant_intents: + # Merge sentences into existing dictionary + merge_dict(intents_dict, lang_variant_intents) + + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded intents language=%s (%s)", + language, + language_variant, + ) + # Check for custom sentences in /custom_sentences// if lang_intents is None: # Only load custom sentences once, otherwise they will be re-loaded # when components change. - for language_variation in language_variations: - custom_sentences_dir = Path( - self.hass.config.path("custom_sentences", language_variation) - ) - if custom_sentences_dir.is_dir(): - for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): - with custom_sentences_path.open( - encoding="utf-8" - ) as custom_sentences_file: - # Merge custom sentences - if isinstance( - custom_sentences_yaml := yaml.safe_load( - custom_sentences_file - ), - dict, - ): - merge_dict(intents_dict, custom_sentences_yaml) - else: - _LOGGER.warning( - "Custom sentences file does not match expected format path=%s", - custom_sentences_file.name, - ) + custom_sentences_dir = Path( + self.hass.config.path("custom_sentences", language_variant) + ) + if custom_sentences_dir.is_dir(): + for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): + with custom_sentences_path.open( + encoding="utf-8" + ) as custom_sentences_file: + # Merge custom sentences + if isinstance( + custom_sentences_yaml := yaml.safe_load( + custom_sentences_file + ), + dict, + ): + # Add metadata so we can identify custom sentences in the debugger + custom_intents_dict = custom_sentences_yaml.get( + "intents", {} + ) + for intent_dict in custom_intents_dict.values(): + intent_data_list = intent_dict.get("data", []) + for intent_data in intent_data_list: + sentence_metadata = intent_data.get("metadata", {}) + sentence_metadata[METADATA_CUSTOM_SENTENCE] = True + sentence_metadata[METADATA_CUSTOM_FILE] = str( + custom_sentences_path.relative_to( + custom_sentences_dir.parent + ) + ) + intent_data["metadata"] = sentence_metadata - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded custom sentences language=%s (%s), path=%s", - language, - language_variation, - custom_sentences_path, - ) + merge_dict(intents_dict, custom_sentences_yaml) + else: + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) - # Stop after first matched language variation - break + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded custom sentences language=%s (%s), path=%s", + language, + language_variant, + custom_sentences_path, + ) # Load sentences from HA config for default language only - if self._config_intents and (language == self.hass.config.language): + if self._config_intents and ( + self.hass.config.language in (language, language_variant) + ): merge_dict( intents_dict, { @@ -524,7 +670,7 @@ class DefaultAgent(AbstractConversationAgent): intents_dict, intent_responses, error_responses, - loaded_components, + language_variant, ) self._lang_intents[language] = lang_intents else: @@ -535,12 +681,16 @@ class DefaultAgent(AbstractConversationAgent): return lang_intents @core.callback - def _async_handle_area_registry_changed(self, event: core.Event) -> None: + def _async_handle_area_registry_changed( + self, event: EventType[ar.EventAreaRegistryUpdatedData] + ) -> None: """Clear area area cache when the area registry has changed.""" self._slot_lists = None @core.callback - def _async_handle_entity_registry_changed(self, event: core.Event) -> None: + def _async_handle_entity_registry_changed( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: """Clear names list cache when an entity registry entry has changed.""" if event.data["action"] != "update" or not any( field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS @@ -549,9 +699,11 @@ class DefaultAgent(AbstractConversationAgent): self._slot_lists = None @core.callback - def _async_handle_state_changed(self, event: core.Event) -> None: + def _async_handle_state_changed( + self, event: EventType[EventStateChangedData] + ) -> None: """Clear names list cache when a state is added or removed from the state machine.""" - if event.data.get("old_state") and event.data.get("new_state"): + if event.data["old_state"] and event.data["new_state"]: return self._slot_lists = None @@ -565,14 +717,12 @@ class DefaultAgent(AbstractConversationAgent): if self._slot_lists is not None: return self._slot_lists - area_ids_with_entities: set[str] = set() entity_registry = er.async_get(self.hass) states = [ state for state in self.hass.states.async_all() if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] @@ -590,39 +740,31 @@ class DefaultAgent(AbstractConversationAgent): if not entity: # Default name - entity_names.append((state.name, state.name, context)) + entity_names.append((state.name, state.entity_id, context)) continue if entity.aliases: for alias in entity.aliases: - entity_names.append((alias, alias, context)) + if not alias.strip(): + continue + + entity_names.append((alias, state.entity_id, context)) # Default name - entity_names.append((state.name, state.name, context)) + entity_names.append((state.name, state.entity_id, context)) - if entity.area_id: - # Expose area too - area_ids_with_entities.add(entity.area_id) - elif entity.device_id: - # Check device for area as well - device = devices.async_get(entity.device_id) - if (device is not None) and device.area_id: - area_ids_with_entities.add(device.area_id) - - # Gather areas from exposed entities + # Expose all areas areas = ar.async_get(self.hass) area_names = [] - for area_id in area_ids_with_entities: - area = areas.async_get_area(area_id) - if area is None: - continue - + for area in areas.async_list_areas(): area_names.append((area.name, area.id)) if area.aliases: for alias in area.aliases: + if not alias.strip(): + continue + area_names.append((alias, area.id)) - _LOGGER.debug("Exposed areas: %s", area_names) _LOGGER.debug("Exposed entities: %s", entity_names) self._slot_lists = { @@ -649,18 +791,25 @@ class DefaultAgent(AbstractConversationAgent): if device_area is None: return None - return {"area": device_area.id} + return {"area": {"value": device_area.id, "text": device_area.name}} def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents | None + self, + error_key: ErrorKey, + 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_key = error_key.value + 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, @@ -711,11 +860,11 @@ class DefaultAgent(AbstractConversationAgent): # Force rebuild on next use self._trigger_intents = None - async def _match_triggers(self, sentence: str) -> ConversationResult | None: + async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None: """Try to match sentence against registered trigger sentences. - Calls the registered callbacks if there's a match and returns a positive - conversation result. + Calls the registered callbacks if there's a match and returns a sentence + trigger result. """ if not self._trigger_sentences: # No triggers registered @@ -728,7 +877,11 @@ class DefaultAgent(AbstractConversationAgent): assert self._trigger_intents is not None matched_triggers: dict[int, RecognizeResult] = {} + matched_template: str | None = None for result in recognize_all(sentence, self._trigger_intents): + if result.intent_sentence is not None: + matched_template = result.intent_sentence.text + trigger_id = int(result.intent.name) if trigger_id in matched_triggers: # Already matched a sentence from this trigger @@ -747,24 +900,7 @@ class DefaultAgent(AbstractConversationAgent): list(matched_triggers), ) - # Gather callback responses in parallel - trigger_responses = await asyncio.gather( - *( - self._trigger_sentences[trigger_id].callback(sentence, result) - for trigger_id, result in matched_triggers.items() - ) - ) - - # Use last non-empty result as speech response - speech: str | None = None - for trigger_response in trigger_responses: - speech = speech or trigger_response - - response = intent.IntentResponse(language=self.hass.config.language) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(speech or "") - - return ConversationResult(response=response) + return SentenceTriggerResult(sentence, matched_template, matched_triggers) def _make_error_result( @@ -780,6 +916,74 @@ def _make_error_result( return ConversationResult(response, conversation_id) +def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: + """Get key and template arguments for error when there are unmatched intent entities/slots.""" + + # Filter out non-text and missing context entities + unmatched_text: dict[str, str] = { + key: entity.text.strip() + for key, entity in result.unmatched_entities.items() + if isinstance(entity, UnmatchedTextEntity) and entity.text != MISSING_ENTITY + } + + if unmatched_area := unmatched_text.get("area"): + # area only + return ErrorKey.NO_AREA, {"area": unmatched_area} + + # Area may still have matched + matched_area: str | None = None + if matched_area_entity := result.entities.get("area"): + matched_area = matched_area_entity.text.strip() + + if unmatched_name := unmatched_text.get("name"): + if matched_area: + # device in area + return ErrorKey.NO_ENTITY_IN_AREA, { + "entity": unmatched_name, + "area": matched_area, + } + + # device only + return ErrorKey.NO_ENTITY, {"entity": unmatched_name} + + # Default error + return ErrorKey.NO_INTENT, {} + + +def _get_no_states_matched_response( + no_states_error: intent.NoStatesMatchedError, +) -> tuple[ErrorKey, dict[str, Any]]: + """Return key and template arguments for error when intent returns no matching states.""" + + # Device classes should be checked before domains + if no_states_error.device_classes: + device_class = next(iter(no_states_error.device_classes)) # first device class + if no_states_error.area: + # device_class in area + return ErrorKey.NO_DEVICE_CLASS_IN_AREA, { + "device_class": device_class, + "area": no_states_error.area, + } + + # device_class only + return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class} + + if no_states_error.domains: + domain = next(iter(no_states_error.domains)) # first domain + if no_states_error.area: + # domain in area + return ErrorKey.NO_DOMAIN_IN_AREA, { + "domain": domain, + "area": no_states_error.area, + } + + # domain only + return ErrorKey.NO_DOMAIN, {"domain": domain} + + # Default error + return ErrorKey.NO_INTENT, {} + + 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 5f0c7b171ae..e4317052b04 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.5.1", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.2"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 953db065614..3846426c3f0 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 8240cfa3f82..255e6cec430 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/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 30cc9a0d5d0..4600135c1e5 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -7,10 +7,11 @@ from hassil.recognize import PUNCTUATION, RecognizeResult import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType from . import HOME_ASSISTANT_AGENT, _get_agent_manager from .const import DOMAIN @@ -60,7 +61,6 @@ async def async_attach_trigger( job = HassJob(action) - @callback async def call_action(sentence: str, result: RecognizeResult) -> str | None: """Call action with right context.""" @@ -91,9 +91,19 @@ async def async_attach_trigger( job, {"trigger": trigger_input}, ): - await future + automation_result = await future + if isinstance( + automation_result, ScriptRunResult + ) and automation_result.conversation_response not in (None, UNDEFINED): + # mypy does not understand the type narrowing, unclear why + return automation_result.conversation_response # type: ignore[return-value] - return "Done" + # It's important to return None here instead of a string. + # + # When editing in the UI, a copy of this trigger is registered. + # If we return a string from here, there is a race condition between the + # two trigger copies for who will provide a response. + return None default_agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) assert isinstance(default_agent, DefaultAgent) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index 2e931d835a8..78fb4bd0ef5 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -1,8 +1,10 @@ """Util for Conversation.""" +from __future__ import annotations + import re -def create_matcher(utterance): +def create_matcher(utterance: str) -> re.Pattern[str]: """Create a regex that matches the utterance.""" # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index eaca8949b14..d01310a6266 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -9,7 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN from .coordinator import CoolmasterDataUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index c9f5cff4339..ecb604a14cc 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -54,6 +54,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" @@ -65,7 +66,10 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self.swing_mode: supported_features |= ClimateEntityFeature.SWING_MODE diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 42676498c9f..7d69025fb97 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.restore_state import RestoreEntity 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 @@ STORAGE_FIELDS = { } -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 @@ class Counter(collection.CollectionEntity, RestoreEntity): @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/counter/icons.json b/homeassistant/components/counter/icons.json new file mode 100644 index 00000000000..1e0ef54bbb7 --- /dev/null +++ b/homeassistant/components/counter/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "decrement": "mdi:numeric-negative-1", + "increment": "mdi:numeric-positive-1", + "reset": "mdi:refresh", + "set_value": "mdi:counter" + } +} diff --git a/homeassistant/components/cover/icons.json b/homeassistant/components/cover/icons.json new file mode 100644 index 00000000000..f2edaaa0893 --- /dev/null +++ b/homeassistant/components/cover/icons.json @@ -0,0 +1,92 @@ +{ + "entity_component": { + "_": { + "default": "mdi:window-open", + "state": { + "closed": "mdi:window-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "blind": { + "default": "mdi:blinds-horizontal", + "state": { + "closed": "mdi:blinds-horizontal-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "curtain": { + "default": "mdi:curtains", + "state": { + "closed": "mdi:curtains-closed", + "closing": "mdi:arrow-collapse-horizontal", + "opening": "mdi:arrow-split-vertical" + } + }, + "damper": { + "default": "mdi:circle", + "state": { + "closed": "mdi:circle-slice-8" + } + }, + "door": { + "default": "mdi:door-open", + "state": { + "closed": "mdi:door-closed" + } + }, + "garage": { + "default": "mdi:garage-open", + "state": { + "closed": "mdi:garage", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "gate": { + "default": "mdi:gate-open", + "state": { + "closed": "mdi:gate", + "closing": "mdi:arrow-right", + "opening": "mdi:arrow-right" + } + }, + "shade": { + "default": "mdi:roller-shade", + "state": { + "closed": "mdi:roller-shade-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "shutter": { + "default": "mdi:window-shutter-open", + "state": { + "closed": "mdi:window-shutter", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "window": { + "default": "mdi:window-open", + "state": { + "closed": "mdi:window-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + } + }, + "services": { + "close_cover": "mdi:arrow-down-box", + "close_cover_tilt": "mdi:arrow-bottom-left", + "open_cover": "mdi:arrow-up-box", + "open_cover_tilt": "mdi:arrow-top-right", + "set_cover_position": "mdi:arrow-down-box", + "set_cover_tilt_position": "mdi:arrow-top-right", + "stop_cover": "mdi:stop", + "stop_cover_tilt": "mdi:stop", + "toggle": "mdi:arrow-up-down", + "toggle_cover_tilt": "mdi:arrow-top-right-bottom-left" + } +} diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index cc79f2ae233..e39fe97bc6c 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -1,7 +1,10 @@ """Platform for the Daikin AC.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import Any from aiohttp import ClientConnectionError from pydaikin.daikin_base import Appliance @@ -68,7 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): +async def daikin_api_setup( + hass: HomeAssistant, + host: str, + key: str | None, + uuid: str | None, + password: str | None, +) -> DaikinApi | None: """Create a Daikin instance only once.""" session = async_get_clientsession(hass) @@ -103,7 +112,7 @@ class DaikinApi: self._available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self, **kwargs): + async def async_update(self, **kwargs: Any) -> None: """Pull the latest data from Daikin.""" try: await self.device.update_status() diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index c848e0b703e..c6bab19aa8a 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities([DaikinClimate(daikin_api)], update_before_add=True) -def format_target_temperature(target_temperature): +def format_target_temperature(target_temperature: float) -> str: """Format target temperature to be sent to the Daikin unit, rounding to nearest half degree.""" return str(round(float(target_temperature) * 2, 0) / 2).rstrip("0").rstrip(".") @@ -126,6 +126,9 @@ class DaikinClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HA_STATE_TO_DAIKIN) _attr_target_temperature_step = 1 + _attr_fan_modes: list[str] + _attr_swing_modes: list[str] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api: DaikinApi) -> None: """Initialize the climate device.""" @@ -134,13 +137,17 @@ class DaikinClimate(ClimateEntity): self._attr_fan_modes = api.device.fan_rate self._attr_swing_modes = api.device.swing_modes self._attr_device_info = api.device_info - self._list = { + self._list: dict[str, list[Any]] = { ATTR_HVAC_MODE: self._attr_hvac_modes, ATTR_FAN_MODE: self._attr_fan_modes, ATTR_SWING_MODE: self._attr_swing_modes, } - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + ) if api.device.support_away_mode or api.device.support_advanced_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE @@ -151,9 +158,9 @@ class DaikinClimate(ClimateEntity): if api.device.support_swing_mode: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE - async def _set(self, settings): + async def _set(self, settings: dict[str, Any]) -> None: """Set device settings using API.""" - values = {} + values: dict[str, Any] = {} for attr in (ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE): if (value := settings.get(attr)) is None: @@ -180,17 +187,17 @@ class DaikinClimate(ClimateEntity): await self._api.device.set(values) @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._api.device.mac @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._api.device.inside_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._api.device.target_temperature @@ -221,7 +228,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_HVAC_MODE: hvac_mode}) @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() @@ -230,7 +237,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_FAN_MODE: fan_mode}) @property - def swing_mode(self): + def swing_mode(self) -> str: """Return the fan setting.""" return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() @@ -239,7 +246,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_SWING_MODE: swing_mode}) @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the preset_mode.""" if ( self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] @@ -282,7 +289,7 @@ class DaikinClimate(ClimateEntity): ) @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """List of available preset modes.""" ret = [PRESET_NONE] if self._api.device.support_away_mode: diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 2d5d1e12dfd..b79cc960fce 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -1,6 +1,9 @@ """Config flow for the Daikin platform.""" +from __future__ import annotations + import asyncio import logging +from typing import Any from uuid import uuid4 from aiohttp import ClientError, web_exceptions @@ -24,12 +27,12 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Daikin config flow.""" - self.host = None + self.host: str | None = None @property - def schema(self): + def schema(self) -> vol.Schema: """Return current schema.""" return vol.Schema( { @@ -39,7 +42,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def _create_entry(self, host, mac, key=None, uuid=None, password=None): + async def _create_entry( + self, + host: str, + mac: str, + key: str | None = None, + uuid: str | None = None, + password: str | None = None, + ) -> FlowResult: """Register new entry.""" if not self.unique_id: await self.async_set_unique_id(mac) @@ -56,7 +66,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def _create_device(self, host, key=None, password=None): + async def _create_device( + self, host: str, key: str | None = None, password: str | None = None + ) -> FlowResult: """Create device.""" # BRP07Cxx devices needs uuid together with key if key: @@ -108,12 +120,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): mac = device.mac return await self._create_entry(host, mac, key, uuid, password) - 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(step_id="user", data_schema=self.schema) if user_input.get(CONF_API_KEY) and user_input.get(CONF_PASSWORD): - self.host = user_input.get(CONF_HOST) + self.host = user_input[CONF_HOST] return self.async_show_form( step_id="user", data_schema=self.schema, diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 7c7f5ce7f2a..0b97ff6b902 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "quality_scale": "platinum", "requirements": ["pydaikin==2.11.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 7acd234e397..8741898237e 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -61,7 +61,7 @@ class DaikinZoneSwitch(SwitchEntity): _attr_icon = ZONE_ICON _attr_has_entity_name = True - def __init__(self, api: DaikinApi, zone_id) -> None: + def __init__(self, api: DaikinApi, zone_id: int) -> None: """Initialize the zone.""" self._api = api self._zone_id = zone_id diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 599edbb7703..5069a62bcdf 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -15,7 +15,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] DOMAIN = "danfoss_air" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) diff --git a/homeassistant/components/date/icons.json b/homeassistant/components/date/icons.json new file mode 100644 index 00000000000..80ec2691285 --- /dev/null +++ b/homeassistant/components/date/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar" + } + }, + "services": { + "set_value": "mdi:calendar-edit" + } +} diff --git a/homeassistant/components/datetime/icons.json b/homeassistant/components/datetime/icons.json new file mode 100644 index 00000000000..563d03e2a8f --- /dev/null +++ b/homeassistant/components/datetime/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar-clock" + } + }, + "services": { + "set_value": "mdi:calendar-edit" + } +} diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index eb1d0d6b672..35a0e810c9e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -100,6 +100,7 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): TYPE = DOMAIN _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Thermostat, gateway: DeconzGateway) -> None: """Set up thermostat device.""" @@ -119,7 +120,11 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): HVAC_MODE_TO_DECONZ[item]: item for item in self._attr_hvac_modes } - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) if device.fan_mode: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 1b257d121b4..70d03f808c1 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -491,6 +491,7 @@ LEGRAND_ZGP_SCENE_SWITCH = { } LIDL_SILVERCREST_DOORBELL_MODEL = "HG06668" +LIDL_SILVERCREST_DOORBELL_MODEL_2 = "TS0211" LIDL_SILVERCREST_DOORBELL = { (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, } @@ -628,6 +629,7 @@ REMOTES = { LEGRAND_ZGP_TOGGLE_SWITCH_MODEL: LEGRAND_ZGP_TOGGLE_SWITCH, LEGRAND_ZGP_SCENE_SWITCH_MODEL: LEGRAND_ZGP_SCENE_SWITCH, LIDL_SILVERCREST_DOORBELL_MODEL: LIDL_SILVERCREST_DOORBELL, + LIDL_SILVERCREST_DOORBELL_MODEL_2: LIDL_SILVERCREST_DOORBELL, LIDL_SILVERCREST_BUTTON_REMOTE_MODEL: LIDL_SILVERCREST_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 044c9bf203b..27038a07ac3 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -212,6 +212,10 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): color_mode = ColorMode.BRIGHTNESS else: color_mode = ColorMode.ONOFF + if color_mode not in self._attr_supported_color_modes: + # Some lights controlled by ZigBee scenes can get unsupported color mode + return self._attr_color_mode + self._attr_color_mode = color_mode return color_mode @property diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 25a9ca311e8..2221bbbef61 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,9 +1,7 @@ """Component providing default configuration for new users.""" -from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component DOMAIN = "default_config" @@ -12,7 +10,4 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" - if not is_hassio(hass): - await async_setup_component(hass, "backup", config) - return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 684013a5633..cbadb704a42 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -3,46 +3,25 @@ "name": "Default Config", "codeowners": ["@home-assistant/core"], "dependencies": [ - "application_credentials", "assist_pipeline", - "automation", "bluetooth", "cloud", "conversation", - "counter", "dhcp", "energy", - "frontend", - "hardware", "history", "homeassistant_alerts", - "input_boolean", - "input_button", - "input_datetime", - "input_number", - "input_select", - "input_text", "logbook", - "logger", "map", "media_source", "mobile_app", "my", - "network", - "person", - "scene", - "schedule", - "script", "ssdp", "stream", "sun", - "system_health", - "tag", - "timer", "usb", "webhook", - "zeroconf", - "zone" + "zeroconf" ], "documentation": "https://www.home-assistant.io/integrations/default_config", "integration_type": "system", diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 0eaa7d5f41f..745a2473939 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -56,10 +56,10 @@ async def async_setup_entry( unit_of_measurement=UnitOfTemperature.CELSIUS, preset=None, current_temperature=22, - fan_mode="On High", + fan_mode="on_high", target_humidity=67, current_humidity=54, - swing_mode="Off", + swing_mode="off", hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING, aux=False, @@ -75,10 +75,10 @@ async def async_setup_entry( preset="home", preset_modes=["home", "eco", "away"], current_temperature=23, - fan_mode="Auto Low", + fan_mode="auto_low", target_humidity=None, current_humidity=None, - swing_mode="Auto", + swing_mode="auto", hvac_mode=HVACMode.HEAT_COOL, hvac_action=None, aux=None, @@ -97,6 +97,7 @@ class DemoClimate(ClimateEntity): _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -137,6 +138,9 @@ class DemoClimate(ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._target_temperature = target_temperature self._target_humidity = target_humidity self._unit_of_measurement = unit_of_measurement diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json new file mode 100644 index 00000000000..79c18bc0a2e --- /dev/null +++ b/homeassistant/components/demo/icons.json @@ -0,0 +1,62 @@ +{ + "entity": { + "climate": { + "ubercool": { + "state_attributes": { + "fan_mode": { + "state": { + "auto_high": "mdi:fan-auto", + "auto_low": "mdi:fan-auto", + "on_high": "mdi:fan-chevron-up", + "on_low": "mdi:fan-chevron-down" + } + }, + "swing_mode": { + "state": { + "1": "mdi:numeric-1", + "2": "mdi:numeric-2", + "3": "mdi:numeric-3", + "auto": "mdi:arrow-oscillating", + "off": "mdi:arrow-oscillating-off" + } + } + } + } + }, + "number": { + "volume": { + "default": "mdi:volume-high" + }, + "pwm": { + "default": "mdi:square-wave" + }, + "range": { + "default": "mdi:square-wave" + } + }, + "select": { + "speed": { + "state": { + "light_speed": "mdi:speedometer-slow", + "ludicrous_speed": "mdi:speedometer-medium", + "ridiculous_speed": "mdi:speedometer" + } + } + }, + "sensor": { + "thermostat_mode": { + "state": { + "away": "mdi:home-export-outline", + "comfort": "mdi:home-account", + "eco": "mdi:leaf", + "sleep": "mdi:weather-night" + } + } + }, + "switch": { + "air_conditioner": { + "default": "mdi:air-conditioner" + } + } + } +} diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 5bc0462769d..db065054804 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -23,7 +23,7 @@ async def async_setup_entry( "volume1", "volume", 42.0, - "mdi:volume-high", + "volume", False, mode=NumberMode.SLIDER, ), @@ -31,7 +31,7 @@ async def async_setup_entry( "pwm1", "PWM 1", 0.42, - "mdi:square-wave", + "pwm", False, native_min_value=0.0, native_max_value=1.0, @@ -42,7 +42,7 @@ async def async_setup_entry( "large_range", "Large Range", 500, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=1000, @@ -52,7 +52,7 @@ async def async_setup_entry( "small_range", "Small Range", 128, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=255, @@ -62,7 +62,7 @@ async def async_setup_entry( "temp1", "Temperature setting", 22, - "mdi:thermometer", + None, False, device_class=NumberDeviceClass.TEMPERATURE, native_min_value=15.0, @@ -87,7 +87,7 @@ class DemoNumber(NumberEntity): unique_id: str, device_name: str, state: float, - icon: str, + translation_key: str | None, assumed_state: bool, *, device_class: NumberDeviceClass | None = None, @@ -100,7 +100,7 @@ class DemoNumber(NumberEntity): """Initialize the Demo Number entity.""" self._attr_assumed_state = assumed_state self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_mode = mode self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 40df72b073b..f4f81a52052 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -19,8 +19,8 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoRemote("Remote One", False, None), - DemoRemote("Remote Two", True, "mdi:remote"), + DemoRemote("Remote One", False), + DemoRemote("Remote Two", True), ] ) @@ -30,11 +30,10 @@ class DemoRemote(RemoteEntity): _attr_should_poll = False - def __init__(self, name: str | None, state: bool, icon: str | None) -> None: + def __init__(self, name: str | None, state: bool) -> None: """Initialize the Demo Remote.""" self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_is_on = state - self._attr_icon = icon self._last_command_sent: str | None = None @property diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index 2a50b0151b6..58244e063f5 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -21,7 +21,6 @@ async def async_setup_entry( DemoSelect( unique_id="speed", device_name="Speed", - icon="mdi:speedometer", current_option="ridiculous_speed", options=[ "light_speed", @@ -45,7 +44,6 @@ class DemoSelect(SelectEntity): self, unique_id: str, device_name: str, - icon: str, current_option: str | None, options: list[str], translation_key: str, @@ -53,7 +51,6 @@ class DemoSelect(SelectEntity): """Initialize the Demo select entity.""" self._attr_unique_id = unique_id self._attr_current_option = current_option - self._attr_icon = icon self._attr_options = options self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index eac267c7c15..ac91b069d8d 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -20,13 +20,13 @@ async def async_setup_entry( """Set up the demo switch platform.""" async_add_entities( [ - DemoSwitch("switch1", "Decorative Lights", True, None, True), + DemoSwitch("switch1", "Decorative Lights", True, True), DemoSwitch( "switch2", "AC", False, - "mdi:air-conditioner", False, + translation_key="air_conditioner", device_class=SwitchDeviceClass.OUTLET, ), ] @@ -45,14 +45,14 @@ class DemoSwitch(SwitchEntity): unique_id: str, device_name: str, state: bool, - icon: str | None, assumed: bool, + translation_key: str | None = None, device_class: SwitchDeviceClass | None = None, ) -> None: """Initialize the Demo switch.""" self._attr_assumed_state = assumed self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_is_on = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index fecc1b95cf4..d7174002055 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -21,20 +21,17 @@ async def async_setup_entry( DemoText( unique_id="text", device_name="Text", - icon=None, native_value="Hello world", ), DemoText( unique_id="password", device_name="Password", - icon="mdi:text", native_value="Hello world", mode=TextMode.PASSWORD, ), DemoText( unique_id="text_1_to_5_char", device_name="Text with 1 to 5 characters", - icon="mdi:text", native_value="Hello", native_min=1, native_max=5, @@ -42,7 +39,6 @@ async def async_setup_entry( DemoText( unique_id="text_lowercase", device_name="Text with only lower case characters", - icon="mdi:text", native_value="world", pattern=r"[a-z]+", ), @@ -61,7 +57,6 @@ class DemoText(TextEntity): self, unique_id: str, device_name: str, - icon: str | None, native_value: str | None, mode: TextMode = TextMode.TEXT, native_max: int | None = None, @@ -71,7 +66,6 @@ class DemoText(TextEntity): """Initialize the Demo text entity.""" self._attr_unique_id = unique_id self._attr_native_value = native_value - self._attr_icon = icon self._attr_mode = mode if native_max is not None: self._attr_native_max = native_max diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index 56ab715a7f7..d0ec87386ef 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the demo time platform.""" - async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), False)]) class DemoTime(TimeEntity): @@ -33,12 +33,10 @@ class DemoTime(TimeEntity): unique_id: str, device_name: str, state: time, - icon: str, assumed_state: bool, ) -> None: """Initialize the Demo time entity.""" self._attr_assumed_state = assumed_state - self._attr_icon = icon self._attr_native_value = state self._attr_unique_id = unique_id diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 83216ebdba6..6ce67dffb90 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -12,7 +12,6 @@ from homeassistant.components.vacuum import ( STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, - VacuumEntity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -23,24 +22,26 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.STATUS + VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP | VacuumEntityFeature.BATTERY ) SUPPORT_MOST_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF + VacuumEntityFeature.STATE + | VacuumEntityFeature.START | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED ) SUPPORT_ALL_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF + VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP | VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME @@ -49,27 +50,17 @@ SUPPORT_ALL_SERVICES = ( | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT ) -SUPPORT_STATE_SERVICES = ( - VacuumEntityFeature.STATE - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT - | VacuumEntityFeature.START -) - FAN_SPEEDS = ["min", "medium", "high", "max"] DEMO_VACUUM_COMPLETE = "0_Ground_floor" DEMO_VACUUM_MOST = "1_First_floor" DEMO_VACUUM_BASIC = "2_Second_floor" DEMO_VACUUM_MINIMAL = "3_Third_floor" DEMO_VACUUM_NONE = "4_Fourth_floor" -DEMO_VACUUM_STATE = "5_Fifth_floor" async def async_setup_entry( @@ -80,18 +71,17 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), - DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), - DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), - DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), - DemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), - StateDemoVacuum(DEMO_VACUUM_STATE), + StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), ] ) -class DemoVacuum(VacuumEntity): - """Representation of a demo vacuum.""" +class StateDemoVacuum(StateVacuumEntity): + """Representation of a demo vacuum supporting states.""" _attr_should_poll = False _attr_translation_key = "model_s" @@ -100,148 +90,6 @@ class DemoVacuum(VacuumEntity): """Initialize the vacuum.""" self._attr_name = name self._attr_supported_features = supported_features - self._state = False - self._status = "Charging" - self._fan_speed = FAN_SPEEDS[1] - self._cleaned_area: float = 0 - self._battery_level = 100 - - @property - def is_on(self) -> bool: - """Return true if vacuum is on.""" - return self._state - - @property - def status(self) -> str: - """Return the status of the vacuum.""" - return self._status - - @property - def fan_speed(self) -> str: - """Return the status of the vacuum.""" - return self._fan_speed - - @property - def fan_speed_list(self) -> list[str]: - """Return the status of the vacuum.""" - return FAN_SPEEDS - - @property - def battery_level(self) -> int: - """Return the status of the vacuum.""" - return max(0, min(100, self._battery_level)) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attributes.""" - return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} - - def turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on.""" - if self.supported_features & VacuumEntityFeature.TURN_ON == 0: - return - - self._state = True - self._cleaned_area += 5.32 - self._battery_level -= 2 - self._status = "Cleaning" - self.schedule_update_ha_state() - - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off.""" - if self.supported_features & VacuumEntityFeature.TURN_OFF == 0: - return - - self._state = False - self._status = "Charging" - self.schedule_update_ha_state() - - def stop(self, **kwargs: Any) -> None: - """Stop the vacuum.""" - if self.supported_features & VacuumEntityFeature.STOP == 0: - return - - self._state = False - self._status = "Stopping the current task" - self.schedule_update_ha_state() - - def clean_spot(self, **kwargs: Any) -> None: - """Perform a spot clean-up.""" - if self.supported_features & VacuumEntityFeature.CLEAN_SPOT == 0: - return - - self._state = True - self._cleaned_area += 1.32 - self._battery_level -= 1 - self._status = "Cleaning spot" - self.schedule_update_ha_state() - - def locate(self, **kwargs: Any) -> None: - """Locate the vacuum (usually by playing a song).""" - if self.supported_features & VacuumEntityFeature.LOCATE == 0: - return - - self._status = "Hi, I'm over here!" - self.schedule_update_ha_state() - - def start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - if self.supported_features & VacuumEntityFeature.PAUSE == 0: - return - - self._state = not self._state - if self._state: - self._status = "Resuming the current task" - self._cleaned_area += 1.32 - self._battery_level -= 1 - else: - self._status = "Pausing the current task" - self.schedule_update_ha_state() - - def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: - """Set the vacuum's fan speed.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED == 0: - return - - if fan_speed in self.fan_speed_list: - self._fan_speed = fan_speed - self.schedule_update_ha_state() - - def return_to_base(self, **kwargs: Any) -> None: - """Tell the vacuum to return to its dock.""" - if self.supported_features & VacuumEntityFeature.RETURN_HOME == 0: - return - - self._state = False - self._status = "Returning home..." - self._battery_level += 5 - self.schedule_update_ha_state() - - def send_command( - self, - command: str, - params: dict[str, Any] | list[Any] | None = None, - **kwargs: Any, - ) -> None: - """Send a command to the vacuum.""" - if self.supported_features & VacuumEntityFeature.SEND_COMMAND == 0: - return - - self._status = f"Executing {command}({params})" - self._state = True - self.schedule_update_ha_state() - - -class StateDemoVacuum(StateVacuumEntity): - """Representation of a demo vacuum supporting states.""" - - _attr_should_poll = False - _attr_supported_features = SUPPORT_STATE_SERVICES - _attr_translation_key = "model_s" - - def __init__(self, name: str) -> None: - """Initialize the vacuum.""" - self._attr_name = name self._state = STATE_DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 @@ -274,9 +122,6 @@ class StateDemoVacuum(StateVacuumEntity): def start(self) -> None: """Start or resume the cleaning task.""" - if self.supported_features & VacuumEntityFeature.START == 0: - return - if self._state != STATE_CLEANING: self._state = STATE_CLEANING self._cleaned_area += 1.32 @@ -285,26 +130,17 @@ class StateDemoVacuum(StateVacuumEntity): def pause(self) -> None: """Pause the cleaning task.""" - if self.supported_features & VacuumEntityFeature.PAUSE == 0: - return - if self._state == STATE_CLEANING: self._state = STATE_PAUSED self.schedule_update_ha_state() def stop(self, **kwargs: Any) -> None: """Stop the cleaning task, do not return to dock.""" - if self.supported_features & VacuumEntityFeature.STOP == 0: - return - self._state = STATE_IDLE self.schedule_update_ha_state() def return_to_base(self, **kwargs: Any) -> None: """Return dock to charging base.""" - if self.supported_features & VacuumEntityFeature.RETURN_HOME == 0: - return - self._state = STATE_RETURNING self.schedule_update_ha_state() @@ -312,9 +148,6 @@ class StateDemoVacuum(StateVacuumEntity): def clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if self.supported_features & VacuumEntityFeature.CLEAN_SPOT == 0: - return - self._state = STATE_CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 @@ -322,13 +155,35 @@ class StateDemoVacuum(StateVacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set the vacuum's fan speed.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED == 0: - return - if fan_speed in self.fan_speed_list: self._fan_speed = fan_speed self.schedule_update_ha_state() + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum's position.""" + await self.hass.services.async_call( + "notify", + "persistent_notification", + service_data={"message": "I'm here!", "title": "Locate request"}, + ) + self._state = STATE_IDLE + self.async_write_ha_state() + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Locate the vacuum's position.""" + self._state = STATE_CLEANING + self.async_write_ha_state() + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to the vacuum.""" + self._state = STATE_IDLE + self.async_write_ha_state() + def __set_state_to_dock(self, _: datetime) -> None: self._state = STATE_DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 8b6907a60f7..125fec7caaa 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -276,7 +276,7 @@ class DenonDevice(MediaPlayerEntity): self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone, event, parameter) -> None: + async def _telnet_callback(self, zone: str, event: str, parameter: str) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine if zone not in (self._receiver.zone, ALL_ZONES): @@ -287,7 +287,7 @@ class DenonDevice(MediaPlayerEntity): # We skip every event except the last one if event == "NSE" and not parameter.startswith("4"): return - if event == "TA" and not parameter.startwith("ANNAME"): + if event == "TA" and not parameter.startswith("ANNAME"): return if event == "HD" and not parameter.startswith("ALBUM"): return @@ -333,17 +333,17 @@ class DenonDevice(MediaPlayerEntity): return DENON_STATE_MAPPING.get(self._receiver.state) @property - def source_list(self): + def source_list(self) -> list[str]: """Return a list of available input sources.""" return self._receiver.input_func_list @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Return boolean if volume is currently muted.""" return self._receiver.muted @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" # Volume is sent in a format like -50.0. Minimum is -80.0, # maximum is 18.0 @@ -352,12 +352,12 @@ class DenonDevice(MediaPlayerEntity): return (float(self._receiver.volume) + 80) / 100 @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" return self._receiver.input_func @property - def sound_mode(self): + def sound_mode(self) -> str | None: """Return the current matched sound mode.""" return self._receiver.sound_mode @@ -368,11 +368,6 @@ class DenonDevice(MediaPlayerEntity): 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.""" @@ -381,19 +376,14 @@ class DenonDevice(MediaPlayerEntity): return MediaType.CHANNEL @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return None - - @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self._receiver.input_func in self._receiver.playing_func_list: return self._receiver.image_url return None @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" if self._receiver.input_func not in self._receiver.playing_func_list: return self._receiver.input_func @@ -402,61 +392,36 @@ class DenonDevice(MediaPlayerEntity): return self._receiver.frequency @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" if self._receiver.artist is not None: return self._receiver.artist return self._receiver.band @property - def media_album_name(self): + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" if self._receiver.album is not None: 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): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" - if self._receiver.power != POWER_ON: + receiver = self._receiver + if receiver.power != POWER_ON: return {} - state_attributes = {} + state_attributes: dict[str, Any] = {} 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 - def dynamic_eq(self): + def dynamic_eq(self) -> bool | None: """Status of DynamicEQ.""" return self._receiver.dynamic_eq @@ -534,17 +499,17 @@ class DenonDevice(MediaPlayerEntity): await self._receiver.async_mute(mute) @async_log_errors - async def async_get_command(self, command: str, **kwargs): + async def async_get_command(self, command: str, **kwargs: Any) -> str: """Send generic command.""" return await self._receiver.async_get_command(command) @async_log_errors - async def async_update_audyssey(self): + async def async_update_audyssey(self) -> None: """Get the latest audyssey information from device.""" await self._receiver.async_update_audyssey() @async_log_errors - async def async_set_dynamic_eq(self, dynamic_eq: bool): + async def async_set_dynamic_eq(self, dynamic_eq: bool) -> None: """Turn DynamicEQ on or off.""" if dynamic_eq: await self._receiver.async_dynamic_eq_on() diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index 71fa77718e6..c400ed0bcce 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -5,6 +5,7 @@ from collections.abc import Callable import logging from denonavr import DenonAVR +import httpx _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class ConnectDenonAVR: zone3: bool, use_telnet: bool, update_audyssey: bool, - async_client_getter: Callable, + async_client_getter: Callable[[], httpx.AsyncClient], ) -> None: """Initialize the class.""" self._async_client_getter = async_client_getter diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 92fff3730a9..3b0b2425aac 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME, CONF_SOURCE, UnitOfTime @@ -66,7 +67,9 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]), + selector.EntitySelectorConfig( + domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] + ), ), } ).extend(OPTIONS_SCHEMA.schema) diff --git a/homeassistant/components/derivative/icons.json b/homeassistant/components/derivative/icons.json new file mode 100644 index 00000000000..d8f2a961c3a --- /dev/null +++ b/homeassistant/components/derivative/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "derivative": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index 68f74dc2858..e1d8986c2dd 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -1,6 +1,7 @@ { "domain": "derivative", "name": "Derivative", + "after_dependencies": ["counter"], "codeowners": ["@afaucogney"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/derivative", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 73d297d7541..cd912ceb24e 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -64,8 +64,6 @@ UNIT_TIME = { UnitOfTime.DAYS: 24 * 60 * 60, } -ICON = "mdi:chart-line" - DEFAULT_ROUND = 3 DEFAULT_TIME_WINDOW = 0 @@ -157,9 +155,9 @@ async def async_setup_platform( class DerivativeSensor(RestoreSensor, SensorEntity): - """Representation of an derivative sensor.""" + """Representation of a derivative sensor.""" - _attr_icon = ICON + _attr_translation_key = "derivative" _attr_should_poll = False def __init__( diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 50f9acf3e1a..20ac365b33b 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -132,6 +132,7 @@ def _async_register_mac( device_entry = dev_reg.async_get(ev.data["device_id"]) if device_entry is None: + # This should not happen, since the device was just created. return # Check if device has a mac @@ -153,8 +154,7 @@ def _async_register_mac( if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None: return - if (entity_entry := ent_reg.async_get(entity_id)) is None: - return + entity_entry = ent_reg.entities[entity_id] # Make sure entity has a config entry and was disabled by the # default disable logic in the integration and new entities @@ -241,12 +241,12 @@ class TrackerEntity(BaseTrackerEntity): @property def latitude(self) -> float | None: """Return latitude value of the device.""" - raise NotImplementedError + return None @property def longitude(self) -> float | None: """Return longitude value of the device.""" - raise NotImplementedError + return None @property def state(self) -> str | None: diff --git a/homeassistant/components/device_tracker/icons.json b/homeassistant/components/device_tracker/icons.json new file mode 100644 index 00000000000..c89053701ba --- /dev/null +++ b/homeassistant/components/device_tracker/icons.json @@ -0,0 +1,13 @@ +{ + "entity_component": { + "_": { + "default": "mdi:account", + "state": { + "not_home": "mdi:account-arrow-right" + } + } + }, + "services": { + "see": "mdi:account-eye" + } +} diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index e27d5a315a5..9f17a653673 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -56,6 +56,7 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit _attr_precision = PRECISION_TENTHS _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 2ff220b9096..939bd5f5000 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -197,7 +197,7 @@ async def _async_get_json_file_response( return web.Response( body=json_data, content_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{filename}.json.txt"'}, + headers={"Content-Disposition": f'attachment; filename="{filename}.json"'}, ) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index f21a03ef748..786f589bf7b 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 - 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 @@ from .coordinator import DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] -@dataclass -class DiscovergyData: - """Discovergy data class to share meters and api client.""" - - api_client: 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=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 38a250a381d..d0e0c272d24 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -15,26 +15,37 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def make_schema(email: str = "", password: str = "") -> vol.Schema: - """Create schema for config flow.""" - return vol.Schema( - { - vol.Required( - CONF_EMAIL, - default=email, - ): str, - vol.Required( - CONF_PASSWORD, - default=password, - ): str, - } - ) +CONFIG_SCHEMA = vol.Schema( + { + vol.Required( + CONF_EMAIL, + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required( + CONF_PASSWORD, + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -42,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - existing_entry: ConfigEntry | None = None + _existing_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -51,15 +62,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="user", - data_schema=make_schema(), + data_schema=CONFIG_SCHEMA, ) return await self._validate_and_save(user_input) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle the initial step.""" - self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) - + self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) return await self._validate_and_save(entry_data, step_id="reauth") async def _validate_and_save( @@ -84,18 +94,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: - if self.existing_entry: - self.hass.config_entries.async_update_entry( - self.existing_entry, + if self._existing_entry: + return self.async_update_reload_and_abort( + entry=self._existing_entry, data={ CONF_EMAIL: user_input[CONF_EMAIL], CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) - await self.hass.config_entries.async_reload( - self.existing_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") # set unique id to title which is the account email await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) @@ -107,6 +113,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id=step_id, - data_schema=make_schema(), + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, + self._existing_entry.data if self._existing_entry else user_input, + ), errors=errors, ) diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index 75c6f97c701..99d559e94bc 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -8,12 +8,11 @@ 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", @@ -27,15 +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] + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] - for meter in data.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 df16551fff2..00513db484b 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable 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,8 +24,8 @@ from homeassistant.helpers.device_registry import DeviceInfo 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 def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: @@ -165,21 +165,20 @@ 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] + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] entities: list[DiscovergySensor] = [] - for meter in data.meters: + for coordinator in coordinators: sensors: tuple[DiscovergySensorEntityDescription, ...] = () - coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] # select sensor descriptions based on meter type and combine with additional sensors - if meter.measurement_type == "ELECTRICITY": + if coordinator.meter.measurement_type == "ELECTRICITY": sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS - elif meter.measurement_type == "GAS": + elif coordinator.meter.measurement_type == "GAS": sensors = GAS_SENSORS + ADDITIONAL_SENSORS entities.extend( - DiscovergySensor(value_key, description, meter, coordinator) + 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) @@ -200,15 +199,15 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt 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/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 62ff2be7d5b..54cca744360 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -332,7 +332,7 @@ class DmsDeviceSource: @property def usn(self) -> str: """Get the USN (Unique Service Name) for the wrapped UPnP device end-point.""" - return self.config_entry.data[CONF_DEVICE_ID] + return self.config_entry.data[CONF_DEVICE_ID] # type: ignore[no-any-return] @property def udn(self) -> str: @@ -347,7 +347,7 @@ class DmsDeviceSource: @property def source_id(self) -> str: """Return a unique ID (slug) for this source for people to use in URLs.""" - return self.config_entry.data[CONF_SOURCE_ID] + return self.config_entry.data[CONF_SOURCE_ID] # type: ignore[no-any-return] @property def icon(self) -> str | None: diff --git a/homeassistant/components/dlna_dms/media_source.py b/homeassistant/components/dlna_dms/media_source.py index c1245997c7a..399398fa5b9 100644 --- a/homeassistant/components/dlna_dms/media_source.py +++ b/homeassistant/components/dlna_dms/media_source.py @@ -25,7 +25,7 @@ from .const import DOMAIN, LOGGER, PATH_OBJECT_ID_FLAG, ROOT_OBJECT_ID, SOURCE_S from .dms import DidlPlayMedia, get_domain_data -async def async_get_media_source(hass: HomeAssistant): +async def async_get_media_source(hass: HomeAssistant) -> DmsMediaSource: """Set up DLNA DMS media source.""" LOGGER.debug("Setting up DLNA media sources") return DmsMediaSource(hass) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index ebe5216ab69..a4b0d34b339 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -23,6 +23,8 @@ from .const import ( DOMAIN, ) +DEFAULT_RETRIES = 2 + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=120) @@ -67,6 +69,7 @@ class WanIpSensor(SensorEntity): 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 @@ class WanIpSensor(SensorEntity): 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/doods/manifest.json b/homeassistant/components/doods/manifest.json index e49f525d0c2..73d7d3754ce 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.2.0"] } diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 9aca80b04da..1922fde3102 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -1,4 +1,6 @@ """Support for functionality to download files.""" +from __future__ import annotations + from http import HTTPStatus import logging import os @@ -61,7 +63,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def download_file(service: ServiceCall) -> None: """Start thread to download file specified in the URL.""" - def do_download(): + def do_download() -> None: """Download the file.""" try: url = service.data[ATTR_URL] diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3e26ee1ea62..79136a27f16 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -614,7 +614,7 @@ async def async_setup_entry( transport = None protocol = None - while hass.state == CoreState.not_running or hass.is_running: + while hass.state is CoreState.not_running or hass.is_running: # Start DSMR asyncio.Protocol reader # Reflect connected state in devices state by setting an @@ -641,7 +641,7 @@ async def async_setup_entry( await protocol.wait_closed() # Unexpected disconnect - if hass.state == CoreState.not_running or hass.is_running: + if hass.state is CoreState.not_running or hass.is_running: stop_listener() transport = None @@ -673,7 +673,7 @@ async def async_setup_entry( update_entities_telegram(None) if stop_listener and ( - hass.state == CoreState.not_running or hass.is_running + hass.state is CoreState.not_running or hass.is_running ): stop_listener() diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index d477bd41a26..c0c3b14566c 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,9 +1,12 @@ """Integrate with DuckDNS.""" -from collections.abc import Callable, Coroutine +from __future__ import annotations + +from collections.abc import Callable, Coroutine, Sequence from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, cast +from aiohttp import ClientSession import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN @@ -50,11 +53,11 @@ SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the DuckDNS component.""" - domain = config[DOMAIN][CONF_DOMAIN] - token = config[DOMAIN][CONF_ACCESS_TOKEN] + domain: str = config[DOMAIN][CONF_DOMAIN] + token: str = config[DOMAIN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) - async def update_domain_interval(_now): + async def update_domain_interval(_now: datetime) -> bool: """Update the DuckDNS entry.""" return await _update_duckdns(session, domain, token) @@ -81,7 +84,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _SENTINEL = object() -async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): +async def _update_duckdns( + session: ClientSession, + domain: str, + token: str, + *, + txt: str | None | object = _SENTINEL, + clear: bool = False, +) -> bool: """Update DuckDNS.""" params = {"domains": domain, "token": token} @@ -91,7 +101,7 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) params["txt"] = "" clear = True else: - params["txt"] = txt + params["txt"] = cast(str, txt) if clear: params["clear"] = "true" @@ -111,11 +121,9 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) def async_track_time_interval_backoff( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, bool]], - intervals, + intervals: Sequence[timedelta], ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" - if not isinstance(intervals, (list, tuple)): - intervals = (intervals,) remove: CALLBACK_TYPE | None = None failed = 0 diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index 5867e2d634e..60578adf6a7 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -1,5 +1,7 @@ """Support for Duotecno binary sensors.""" +from __future__ import annotations +from duotecno.controller import PyDuotecno from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity @@ -17,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno binary sensor on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoBinarySensor(channel) for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index dc10e0a61d9..3df80721af4 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -1,6 +1,9 @@ """Support for Duotecno climate devices.""" +from __future__ import annotations + from typing import Any, Final +from duotecno.controller import PyDuotecno from duotecno.unit import SensUnit from homeassistant.components.climate import ( @@ -33,7 +36,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno climate based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) ) @@ -44,12 +47,16 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _unit: SensUnit _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HVACMODE_REVERSE) _attr_preset_modes = list(PRESETMODES) _attr_translation_key = "duotecno" + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 0be9daf572b..b8802c77304 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import DuoswitchUnit from homeassistant.components.cover import CoverEntity, CoverEntityFeature @@ -20,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the duoswitch endities.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit") ) @@ -30,13 +31,9 @@ class DuotecnoCover(DuotecnoEntity, CoverEntity): """Representation a Velbus cover.""" _unit: DuoswitchUnit - - def __init__(self, unit: DuoswitchUnit) -> None: - """Initialize the cover.""" - super().__init__(unit) - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index 8d905979bfe..85566b3ebad 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -17,10 +17,9 @@ from .const import DOMAIN class DuotecnoEntity(Entity): """Representation of a Duotecno entity.""" - _attr_should_poll: bool = False - _unit: BaseUnit + _attr_should_poll = False - def __init__(self, unit) -> None: + def __init__(self, unit: BaseUnit) -> None: """Initialize a Duotecno entity.""" self._unit = unit self._attr_name = unit.get_name() diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py index 9aee4513fca..851dd64bfb2 100644 --- a/homeassistant/components/duotecno/light.py +++ b/homeassistant/components/duotecno/light.py @@ -1,6 +1,7 @@ """Support for Duotecno lights.""" from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import DimUnit from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity @@ -18,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno light based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit")) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 9f6d082cae8..7b33784a612 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==2024.1.1"] + "requirements": ["pyDuotecno==2024.1.2"] } diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index 63bab750543..d43f82fc657 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -1,6 +1,7 @@ """Support for Duotecno switches.""" from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity @@ -18,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") ) diff --git a/homeassistant/components/dwd_weather_warnings/icons.json b/homeassistant/components/dwd_weather_warnings/icons.json new file mode 100644 index 00000000000..abee79acf21 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "current_warning_level": { + "default": "mdi:close-octagon-outline" + }, + "advance_warning_level": { + "default": "mdi:close-octagon-outline" + } + } + } +} diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 1a497b64ae3..e74ea6fe862 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], - "requirements": ["dwdwfsapi==1.0.6"] + "requirements": ["dwdwfsapi==1.0.7"] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index e88fb3c408b..d3e3b4a3772 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -44,12 +44,10 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CURRENT_WARNING_SENSOR, translation_key=CURRENT_WARNING_SENSOR, - icon="mdi:close-octagon-outline", ), SensorEntityDescription( key=ADVANCE_WARNING_SENSOR, translation_key=ADVANCE_WARNING_SENSOR, - icon="mdi:close-octagon-outline", ), ) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 83cc639d1da..f46719febb1 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -6,7 +6,7 @@ from homeassistant.const import CONF_ROOM, Platform LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.COVER] +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SWITCH] CONF_ACTIVE = "active" diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6f57ea6ed5f..4dcce0fd705 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==2.1.0"] + "requirements": ["easyenergy==2.1.1"] } diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index a68dfcb791c..95763e5db25 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -111,7 +111,8 @@ def __get_coordinator( }, ) - return hass.data[DOMAIN][entry_id] + coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + return coordinator async def __get_prices( diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 1b0e65f7390..58a3cb09997 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -115,9 +115,12 @@ SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" -DTGROUP_INCLUSIVE_MSG = ( - f"{ATTR_START_DATE}, {ATTR_START_TIME}, {ATTR_END_DATE}, " - f"and {ATTR_END_TIME} must be specified together" +DTGROUP_START_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" +) + +DTGROUP_END_INCLUSIVE_MSG = ( + f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" ) CREATE_VACATION_SCHEMA = vol.Schema( @@ -127,13 +130,17 @@ CREATE_VACATION_SCHEMA = vol.Schema( vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), vol.Inclusive( - ATTR_START_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG ): ecobee_date, vol.Inclusive( - ATTR_START_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive( + ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG ): ecobee_time, - vol.Inclusive(ATTR_END_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_date, - vol.Inclusive(ATTR_END_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_time, vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( int, vol.Range(min=0, max=60) @@ -316,6 +323,7 @@ class Thermostat(ClimateEntity): _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True + _enable_turn_on_off_backwards_compatibility = False def __init__( self, data: EcobeeData, thermostat_index: int, thermostat: dict @@ -368,6 +376,10 @@ class Thermostat(ClimateEntity): supported = supported | ClimateEntityFeature.TARGET_HUMIDITY if self.has_aux_heat: supported = supported | ClimateEntityFeature.AUX_HEAT + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + supported = ( + supported | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) return supported @property diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 1160cd946d9..f3f5b59a36f 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,7 +1,7 @@ { "domain": "ecobee", "name": "ecobee", - "codeowners": ["@marcolivierarsenault"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index fc43fc3000e..484d5bf1e1e 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -52,7 +52,7 @@ }, "start_date": { "name": "Start date", - "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time, end_date, and end_time)." + "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time)." }, "start_time": { "name": "Start time", @@ -60,7 +60,7 @@ }, "end_date": { "name": "End date", - "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with start_date, start_time, and end_time)." + "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with end_time)." }, "end_time": { "name": "End time", diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 67cbd7496e3..5728c87938b 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -27,8 +27,8 @@ from .const import API_CLIENT, DOMAIN, EQUIPMENT _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - Platform.CLIMATE, Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index f5328da4776..ac812a07566 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -66,6 +66,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat): """Initialize.""" @@ -79,12 +80,13 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ha_mode = ECONET_STATE_TO_HA[mode] self._attr_hvac_modes.append(ha_mode) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self._econet.supports_humidifier: - return SUPPORT_FLAGS_THERMOSTAT | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS_THERMOSTAT + self._attr_supported_features |= SUPPORT_FLAGS_THERMOSTAT + if thermostat.supports_humidifier: + self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 9cb8a8c38d8..ce7222f96a2 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -1,28 +1,14 @@ """Support for Ecovacs Deebot vacuums.""" -import logging -import random -import string - -from sucks import EcoVacsAPI, VacBot import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "ecovacs" - -CONF_COUNTRY = "country" -CONF_CONTINENT = "continent" +from .const import CONF_CONTINENT, DOMAIN +from .controller import EcovacsController CONFIG_SCHEMA = vol.Schema( { @@ -38,68 +24,44 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ECOVACS_DEVICES = "ecovacs_devices" - -# Generate a random device ID on each bootup -ECOVACS_API_DEVICEID = "".join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(8) -) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.IMAGE, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ecovacs component.""" - _LOGGER.debug("Creating new Ecovacs component") - - def get_devices() -> list[VacBot]: - ecovacs_api = EcoVacsAPI( - ECOVACS_API_DEVICEID, - config[DOMAIN].get(CONF_USERNAME), - EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), - config[DOMAIN].get(CONF_COUNTRY), - config[DOMAIN].get(CONF_CONTINENT), - ) - ecovacs_devices = ecovacs_api.devices() - _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) - - devices: list[VacBot] = [] - for device in ecovacs_devices: - _LOGGER.info( - "Discovered Ecovacs device on account: %s with nickname %s", - device.get("did"), - device.get("nick"), - ) - vacbot = VacBot( - ecovacs_api.uid, - ecovacs_api.REALM, - ecovacs_api.resource, - ecovacs_api.user_access_token, - device, - config[DOMAIN].get(CONF_CONTINENT).lower(), - monitor=True, - ) - - devices.append(vacbot) - return devices - - hass.data[ECOVACS_DEVICES] = await hass.async_add_executor_job(get_devices) - - async def async_stop(event: object) -> None: - """Shut down open connections to Ecovacs XMPP server.""" - devices: list[VacBot] = hass.data[ECOVACS_DEVICES] - for device in devices: - _LOGGER.info( - "Shutting down connection to Ecovacs device %s", - device.vacuum.get("did"), - ) - await hass.async_add_executor_job(device.disconnect) - - # Listen for HA stop to disconnect. - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - - if hass.data[ECOVACS_DEVICES]: - _LOGGER.debug("Starting vacuum components") + if DOMAIN in config: hass.async_create_task( - discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config) + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) ) - return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + controller = EcovacsController(hass, entry.data) + await controller.initialize() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hass.data[DOMAIN][entry.entry_id].teardown() + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py new file mode 100644 index 00000000000..95e87a04b18 --- /dev/null +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -0,0 +1,74 @@ +"""Binary sensor module.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilityEvent +from deebot_client.events.water_info import WaterInfoEvent + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsBinarySensorEntityDescription( + BinarySensorEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Class describing Deebot binary sensor entity.""" + + value_fn: Callable[[EventT], bool | None] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( + EcovacsBinarySensorEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + value_fn=lambda e: e.mop_attached, + key="water_mop_attached", + translation_key="water_mop_attached", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + get_supported_entitites(controller, EcovacsBinarySensor, ENTITY_DESCRIPTIONS) + ) + + +class EcovacsBinarySensor( + EcovacsDescriptionEntity[CapabilityEvent[EventT]], + BinarySensorEntity, +): + """Ecovacs binary sensor.""" + + entity_description: EcovacsBinarySensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_is_on = self.entity_description.value_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py new file mode 100644 index 00000000000..c2e5458c2ed --- /dev/null +++ b/homeassistant/components/ecovacs/button.py @@ -0,0 +1,108 @@ +"""Ecovacs button module.""" +from dataclasses import dataclass + +from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +from deebot_client.events import LifeSpan + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SUPPORTED_LIFESPANS +from .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsButtonEntityDescription( + ButtonEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs button entity description.""" + + +@dataclass(kw_only=True, frozen=True) +class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): + """Ecovacs lifespan button entity description.""" + + component: LifeSpan + + +ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( + EcovacsButtonEntityDescription( + capability_fn=lambda caps: caps.map.relocation if caps.map else None, + key="relocate", + translation_key="relocate", + entity_category=EntityCategory.CONFIG, + ), +) + +LIFESPAN_ENTITY_DESCRIPTIONS = tuple( + EcovacsLifespanButtonEntityDescription( + component=component, + key=f"reset_lifespan_{component.name.lower()}", + translation_key=f"reset_lifespan_{component.name.lower()}", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ) + for component in SUPPORTED_LIFESPANS +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS + ) + for device in controller.devices: + lifespan_capability = device.capabilities.life_span + for description in LIFESPAN_ENTITY_DESCRIPTIONS: + if description.component in lifespan_capability.types: + entities.append( + EcovacsResetLifespanButtonEntity( + device, lifespan_capability, description + ) + ) + + if entities: + async_add_entities(entities) + + +class EcovacsButtonEntity( + EcovacsDescriptionEntity[CapabilityExecute], + ButtonEntity, +): + """Ecovacs button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command(self._capability.execute()) + + +class EcovacsResetLifespanButtonEntity( + EcovacsDescriptionEntity[CapabilityLifeSpan], + ButtonEntity, +): + """Ecovacs reset lifespan button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command( + self._capability.reset(self.entity_description.component) + ) diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py new file mode 100644 index 00000000000..db3c60fa9e7 --- /dev/null +++ b/homeassistant/components/ecovacs/config_flow.py @@ -0,0 +1,313 @@ +"""Config flow for Ecovacs mqtt integration.""" +from __future__ import annotations + +import logging +import ssl +from typing import Any, cast +from urllib.parse import urlparse + +from aiohttp import ClientError +from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.const import UNDEFINED, UndefinedType +from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.mqtt_client import MqttClient, create_mqtt_config +from deebot_client.util import md5 +from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import aiohttp_client, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.loader import async_get_issue_tracker +from homeassistant.util.ssl import get_default_no_verify_context + +from .const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, + DOMAIN, + InstanceMode, +) +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +def _validate_url( + value: str, + field_name: str, + schema_list: set[str], +) -> dict[str, str]: + """Validate an URL and return error dictionary.""" + if urlparse(value).scheme not in schema_list: + return {field_name: f"invalid_url_schema_{field_name}"} + try: + vol.Schema(vol.Url())(value) + except vol.Invalid: + return {field_name: "invalid_url"} + return {} + + +async def _validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: + """Validate user input.""" + errors: dict[str, str] = {} + + if rest_url := user_input.get(CONF_OVERRIDE_REST_URL): + errors.update( + _validate_url(rest_url, CONF_OVERRIDE_REST_URL, {"http", "https"}) + ) + if mqtt_url := user_input.get(CONF_OVERRIDE_MQTT_URL): + errors.update( + _validate_url(mqtt_url, CONF_OVERRIDE_MQTT_URL, {"mqtt", "mqtts"}) + ) + + if errors: + return errors + + device_id = get_client_device_id() + country = user_input[CONF_COUNTRY] + rest_config = create_rest_config( + aiohttp_client.async_get_clientsession(hass), + device_id=device_id, + alpha_2_country=country, + override_rest_url=rest_url, + ) + + authenticator = Authenticator( + rest_config, + user_input[CONF_USERNAME], + md5(user_input[CONF_PASSWORD]), + ) + + try: + await authenticator.authenticate() + except ClientError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception during login") + errors["base"] = "unknown" + + if errors: + return errors + + ssl_context: UndefinedType | ssl.SSLContext = UNDEFINED + if not user_input.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url: + ssl_context = get_default_no_verify_context() + + mqtt_config = create_mqtt_config( + device_id=device_id, + country=country, + override_mqtt_url=mqtt_url, + ssl_context=ssl_context, + ) + + client = MqttClient(mqtt_config, authenticator) + cannot_connect_field = CONF_OVERRIDE_MQTT_URL if mqtt_url else "base" + + try: + await client.verify_config() + except MqttError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors[cannot_connect_field] = "cannot_connect" + except InvalidAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception during mqtt connection verification") + errors["base"] = "unknown" + + return errors + + +class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecovacs.""" + + VERSION = 1 + + _mode: InstanceMode = InstanceMode.CLOUD + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if not self.show_advanced_options: + return await self.async_step_auth() + + if user_input: + self._mode = user_input[CONF_MODE] + return await self.async_step_auth() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODE, default=InstanceMode.CLOUD + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(InstanceMode), + translation_key="installation_mode", + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + last_step=False, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the auth step.""" + errors = {} + + if user_input: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + + errors = await _validate_input(self.hass, user_input) + + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + schema = { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_COUNTRY): selector.CountrySelector(), + } + if self._mode == InstanceMode.SELF_HOSTED: + schema.update( + { + vol.Required(CONF_OVERRIDE_REST_URL): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.URL) + ), + vol.Required(CONF_OVERRIDE_MQTT_URL): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.URL) + ), + } + ) + if errors: + schema[vol.Optional(CONF_VERIFY_MQTT_CERTIFICATE, default=True)] = bool + + if not user_input: + user_input = { + CONF_COUNTRY: self.hass.config.country, + } + + return self.async_show_form( + step_id="auth", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema(schema), suggested_values=user_input + ), + errors=errors, + last_step=True, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + + def create_repair( + error: str | None = None, placeholders: dict[str, Any] | None = None + ) -> None: + if placeholders is None: + placeholders = {} + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + translation_placeholders=placeholders + | {"url": "/config/integrations/dashboard/add?domain=ecovacs"}, + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders=placeholders + | { + "domain": DOMAIN, + "integration_title": "Ecovacs", + }, + ) + + # We need to validate the imported country and continent + # as the YAML configuration allows any string for them. + # The config flow allows only valid alpha-2 country codes + # through the CountrySelector. + # The continent will be calculated with the function get_continent + # from the country code and there is no need to specify the continent anymore. + # As the YAML configuration includes the continent, + # we check if both the entered continent and the calculated continent match. + # If not we will inform the user about the mismatch. + error = None + placeholders = None + + # Convert the country to upper case as ISO 3166-1 alpha-2 country codes are upper case + user_input[CONF_COUNTRY] = user_input[CONF_COUNTRY].upper() + + if len(user_input[CONF_COUNTRY]) != 2: + error = "invalid_country_length" + placeholders = {"countries_url": "https://www.iso.org/obp/ui/#search/code/"} + elif len(user_input[CONF_CONTINENT]) != 2: + error = "invalid_continent_length" + placeholders = { + "continent_list": ",".join( + sorted(set(COUNTRIES_TO_CONTINENTS.values())) + ) + } + elif user_input[CONF_CONTINENT].lower() != ( + continent := get_continent(user_input[CONF_COUNTRY]) + ): + error = "continent_not_match" + placeholders = { + "continent": continent, + "github_issue_url": cast( + str, async_get_issue_tracker(self.hass, integration_domain=DOMAIN) + ), + } + + if error: + create_repair(error, placeholders) + return self.async_abort(reason=error) + + # Remove the continent from the user input as it is not needed anymore + user_input.pop(CONF_CONTINENT) + try: + result = await self.async_step_auth(user_input) + except AbortFlow as ex: + if ex.reason == "already_configured": + create_repair() + raise ex + + if errors := result.get("errors"): + error = errors["base"] + create_repair(error) + return self.async_abort(reason=error) + + create_repair() + return result diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py new file mode 100644 index 00000000000..dc055cee519 --- /dev/null +++ b/homeassistant/components/ecovacs/const.py @@ -0,0 +1,24 @@ +"""Ecovacs constants.""" +from enum import StrEnum + +from deebot_client.events import LifeSpan + +DOMAIN = "ecovacs" + +CONF_CONTINENT = "continent" +CONF_OVERRIDE_REST_URL = "override_rest_url" +CONF_OVERRIDE_MQTT_URL = "override_mqtt_url" +CONF_VERIFY_MQTT_CERTIFICATE = "verify_mqtt_certificate" + +SUPPORTED_LIFESPANS = ( + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, +) + + +class InstanceMode(StrEnum): + """Instance mode.""" + + CLOUD = "cloud" + SELF_HOSTED = "self_hosted" diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py new file mode 100644 index 00000000000..27b64db20b6 --- /dev/null +++ b/homeassistant/components/ecovacs/controller.py @@ -0,0 +1,116 @@ +"""Controller module.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +import ssl +from typing import Any + +from deebot_client.api_client import ApiClient +from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.const import UNDEFINED, UndefinedType +from deebot_client.device import Device +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +from deebot_client.models import DeviceInfo +from deebot_client.mqtt_client import MqttClient, create_mqtt_config +from deebot_client.util import md5 +from deebot_client.util.continents import get_continent +from sucks import EcoVacsAPI, VacBot + +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_no_verify_context + +from .const import ( + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, +) +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +class EcovacsController: + """Ecovacs controller.""" + + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: + """Initialize controller.""" + self._hass = hass + self.devices: list[Device] = [] + self.legacy_devices: list[VacBot] = [] + self._device_id = get_client_device_id() + country = config[CONF_COUNTRY] + self._continent = get_continent(country) + + self._authenticator = Authenticator( + create_rest_config( + aiohttp_client.async_get_clientsession(self._hass), + device_id=self._device_id, + alpha_2_country=country, + override_rest_url=config.get(CONF_OVERRIDE_REST_URL), + ), + config[CONF_USERNAME], + md5(config[CONF_PASSWORD]), + ) + self._api_client = ApiClient(self._authenticator) + + mqtt_url = config.get(CONF_OVERRIDE_MQTT_URL) + ssl_context: UndefinedType | ssl.SSLContext = UNDEFINED + if not config.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url: + ssl_context = get_default_no_verify_context() + + self._mqtt = MqttClient( + create_mqtt_config( + device_id=self._device_id, + country=country, + override_mqtt_url=mqtt_url, + ssl_context=ssl_context, + ), + self._authenticator, + ) + + async def initialize(self) -> None: + """Init controller.""" + mqtt_config_verfied = False + try: + devices = await self._api_client.get_devices() + credentials = await self._authenticator.authenticate() + for device_config in devices: + if isinstance(device_config, DeviceInfo): + # MQTT device + if not mqtt_config_verfied: + await self._mqtt.verify_config() + mqtt_config_verfied = True + device = Device(device_config, self._authenticator) + await device.initialize(self._mqtt) + self.devices.append(device) + else: + # Legacy device + bot = VacBot( + credentials.user_id, + EcoVacsAPI.REALM, + self._device_id[0:8], + credentials.token, + device_config, + self._continent, + monitor=True, + ) + self.legacy_devices.append(bot) + except InvalidAuthenticationError as ex: + raise ConfigEntryError("Invalid credentials") from ex + except DeebotError as ex: + raise ConfigEntryNotReady("Error during setup") from ex + + _LOGGER.debug("Controller initialize complete") + + async def teardown(self) -> None: + """Disconnect controller.""" + for device in self.devices: + await device.teardown() + for legacy_device in self.legacy_devices: + await self._hass.async_add_executor_job(legacy_device.disconnect) + await self._mqtt.disconnect() + await self._authenticator.teardown() diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py new file mode 100644 index 00000000000..d961e231631 --- /dev/null +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -0,0 +1,42 @@ +"""Ecovacs diagnostics.""" +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_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL, DOMAIN +from .controller import EcovacsController + +REDACT_CONFIG = { + CONF_USERNAME, + CONF_PASSWORD, + "title", + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, +} +REDACT_DEVICE = {"did", CONF_NAME, "homeId"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + diag: dict[str, Any] = { + "config": async_redact_data(config_entry.as_dict(), REDACT_CONFIG) + } + + diag["devices"] = [ + async_redact_data(device.device_info.api_device_info, REDACT_DEVICE) + for device in controller.devices + ] + diag["legacy_devices"] = [ + async_redact_data(device.vacuum, REDACT_DEVICE) + for device in controller.legacy_devices + ] + + return diag diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py new file mode 100644 index 00000000000..20de6914700 --- /dev/null +++ b/homeassistant/components/ecovacs/entity.py @@ -0,0 +1,118 @@ +"""Ecovacs mqtt entity module.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import AvailabilityEvent +from deebot_client.events.base import Event + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import DOMAIN + +CapabilityT = TypeVar("CapabilityT") +EventT = TypeVar("EventT", bound=Event) + + +class EcovacsEntity(Entity, Generic[CapabilityT]): + """Ecovacs entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _always_available: bool = False + + def __init__( + self, + device: Device, + capability: CapabilityT, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(**kwargs) + self._attr_unique_id = f"{device.device_info.did}_{self.entity_description.key}" + + self._device = device + self._capability = capability + self._subscribed_events: set[type[Event]] = set() + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + device_info = self._device.device_info + info = DeviceInfo( + identifiers={(DOMAIN, device_info.did)}, + manufacturer="Ecovacs", + sw_version=self._device.fw_version, + serial_number=device_info.name, + ) + + if nick := device_info.api_device_info.get("nick"): + info["name"] = nick + + if model := device_info.api_device_info.get("deviceName"): + info["model"] = model + + if mac := self._device.mac: + info["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + + return info + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + if not self._always_available: + + async def on_available(event: AvailabilityEvent) -> None: + self._attr_available = event.available + self.async_write_ha_state() + + self._subscribe(AvailabilityEvent, on_available) + + def _subscribe( + self, + event_type: type[EventT], + callback: Callable[[EventT], Coroutine[Any, Any, None]], + ) -> None: + """Subscribe to events.""" + self._subscribed_events.add(event_type) + self.async_on_remove(self._device.events.subscribe(event_type, callback)) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + for event_type in self._subscribed_events: + self._device.events.request_refresh(event_type) + + +class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT]): + """Ecovacs entity.""" + + def __init__( + self, + device: Device, + capability: CapabilityT, + entity_description: EntityDescription, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + self.entity_description = entity_description + super().__init__(device, capability, **kwargs) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsCapabilityEntityDescription( + EntityDescription, + Generic[CapabilityT], +): + """Ecovacs entity description.""" + + capability_fn: Callable[[Capabilities], CapabilityT | None] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json new file mode 100644 index 00000000000..b639ff81e63 --- /dev/null +++ b/homeassistant/components/ecovacs/icons.json @@ -0,0 +1,100 @@ +{ + "entity": { + "binary_sensor": { + "water_mop_attached": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + } + }, + "button": { + "relocate": { + "default": "mdi:map-marker-question" + }, + "reset_lifespan_brush": { + "default": "mdi:broom" + }, + "reset_lifespan_filter": { + "default": "mdi:air-filter" + }, + "reset_lifespan_side_brush": { + "default": "mdi:broom" + } + }, + "number": { + "clean_count": { + "default": "mdi:counter" + }, + "volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + } + }, + "sensor": { + "error": { + "default": "mdi:alert-circle" + }, + "lifespan_brush": { + "default": "mdi:broom" + }, + "lifespan_filter": { + "default": "mdi:air-filter" + }, + "lifespan_side_brush": { + "default": "mdi:broom" + }, + "network_ip": { + "default": "mdi:ip-network-outline" + }, + "network_rssi": { + "default": "mdi:signal-variant" + }, + "network_ssid": { + "default": "mdi:wifi" + }, + "stats_area": { + "default": "mdi:floor-plan" + }, + "stats_time": { + "default": "mdi:timer-outline" + }, + "total_stats_area": { + "default": "mdi:floor-plan" + }, + "total_stats_time": { + "default": "mdi:timer-outline" + }, + "total_stats_cleanings": { + "default": "mdi:counter" + } + }, + "select": { + "water_amount": { + "default": "mdi:water" + }, + "work_mode": { + "default": "mdi:cog" + } + }, + "switch": { + "advanced_mode": { + "default": "mdi:tune" + }, + "carpet_auto_fan_boost": { + "default": "mdi:fan-auto" + }, + "clean_preference": { + "default": "mdi:broom" + }, + "continuous_cleaning": { + "default": "mdi:refresh-auto" + }, + "true_detect": { + "default": "mdi:laser-pointer" + } + } + } +} diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py new file mode 100644 index 00000000000..18c162138fb --- /dev/null +++ b/homeassistant/components/ecovacs/image.py @@ -0,0 +1,84 @@ +"""Ecovacs image entities.""" + +from deebot_client.capabilities import CapabilityMap +from deebot_client.device import Device +from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device in controller.devices: + if caps := device.capabilities.map: + entities.append(EcovacsMap(device, caps, hass)) + + if entities: + async_add_entities(entities) + + +class EcovacsMap( + EcovacsEntity[CapabilityMap], + ImageEntity, +): + """Ecovacs map.""" + + _attr_content_type = "image/svg+xml" + + def __init__( + self, + device: Device, + capability: CapabilityMap, + hass: HomeAssistant, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, hass=hass) + self._attr_extra_state_attributes = {} + + entity_description = EntityDescription( + key="map", + translation_key="map", + ) + + def image(self) -> bytes | None: + """Return bytes of image or None.""" + if svg := self._device.map.get_svg_map(): + return svg.encode() + + return None + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_info(event: CachedMapInfoEvent) -> None: + self._attr_extra_state_attributes["map_name"] = event.name + + async def on_changed(event: MapChangedEvent) -> None: + self._attr_image_last_updated = event.when + self.async_write_ha_state() + + self._subscribe(self._capability.chached_info.event, on_info) + self._subscribe(self._capability.changed.event, on_changed) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await super().async_update() + self._device.map.refresh() diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index eb8afcf0878..34760ea6aca 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,9 +1,10 @@ { "domain": "ecovacs", "name": "Ecovacs", - "codeowners": ["@OverloadUT", "@mib1185"], + "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", - "loggers": ["sleekxmppfs", "sucks"], - "requirements": ["py-sucks==0.9.8"] + "loggers": ["sleekxmppfs", "sucks", "deebot_client"], + "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py new file mode 100644 index 00000000000..45250ab69b1 --- /dev/null +++ b/homeassistant/components/ecovacs/number.py @@ -0,0 +1,103 @@ +"""Ecovacs number module.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilitySet +from deebot_client.events import CleanCountEvent, VolumeEvent + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +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 .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, + EventT, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsNumberEntityDescription( + NumberEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs number entity description.""" + + native_max_value_fn: Callable[[EventT], float | int | None] = lambda _: None + value_fn: Callable[[EventT], float | None] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( + EcovacsNumberEntityDescription[VolumeEvent]( + capability_fn=lambda caps: caps.settings.volume, + value_fn=lambda e: e.volume, + native_max_value_fn=lambda e: e.maximum, + key="volume", + translation_key="volume", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=10, + native_step=1.0, + ), + EcovacsNumberEntityDescription[CleanCountEvent]( + capability_fn=lambda caps: caps.clean.count, + value_fn=lambda e: e.count, + key="clean_count", + translation_key="clean_count", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=1, + native_max_value=4, + native_step=1.0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsNumberEntity( + EcovacsDescriptionEntity[CapabilitySet[EventT, int]], + NumberEntity, +): + """Ecovacs number entity.""" + + entity_description: EcovacsNumberEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_native_value = self.entity_description.value_fn(event) + if maximum := self.entity_description.native_max_value_fn(event): + self._attr_native_max_value = maximum + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self._device.execute_command(self._capability.set(int(value))) diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py new file mode 100644 index 00000000000..cd1cdd10379 --- /dev/null +++ b/homeassistant/components/ecovacs/select.py @@ -0,0 +1,101 @@ +"""Ecovacs select entity module.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic + +from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.device import Device +from deebot_client.events import WaterInfoEvent, WorkModeEvent + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSelectEntityDescription( + SelectEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs select entity description.""" + + current_option_fn: Callable[[EventT], str | None] + options_fn: Callable[[CapabilitySetTypes], list[str]] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( + EcovacsSelectEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + current_option_fn=lambda e: e.amount.display_name, + options_fn=lambda water: [amount.display_name for amount in water.types], + key="water_amount", + translation_key="water_amount", + entity_category=EntityCategory.CONFIG, + ), + EcovacsSelectEntityDescription[WorkModeEvent]( + capability_fn=lambda caps: caps.clean.work_mode, + current_option_fn=lambda e: e.mode.display_name, + options_fn=lambda cap: [mode.display_name for mode in cap.types], + key="work_mode", + translation_key="work_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities = get_supported_entitites( + controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSelectEntity( + EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], + SelectEntity, +): + """Ecovacs select entity.""" + + _attr_current_option: str | None = None + entity_description: EcovacsSelectEntityDescription + + def __init__( + self, + device: Device, + capability: CapabilitySetTypes[EventT, str], + entity_description: EcovacsSelectEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, entity_description, **kwargs) + self._attr_options = entity_description.options_fn(capability) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_current_option = self.entity_description.current_option_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._device.execute_command(self._capability.set(option)) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py new file mode 100644 index 00000000000..16a1b4acd43 --- /dev/null +++ b/homeassistant/components/ecovacs/sensor.py @@ -0,0 +1,254 @@ +"""Ecovacs sensor module.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan +from deebot_client.events import ( + BatteryEvent, + ErrorEvent, + Event, + LifeSpan, + LifeSpanEvent, + NetworkInfoEvent, + StatsEvent, + TotalStatsEvent, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + AREA_SQUARE_METERS, + ATTR_BATTERY_LEVEL, + CONF_DESCRIPTION, + PERCENTAGE, + EntityCategory, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN, SUPPORTED_LIFESPANS +from .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, + EventT, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSensorEntityDescription( + EcovacsCapabilityEntityDescription, + SensorEntityDescription, + Generic[EventT], +): + """Ecovacs sensor entity description.""" + + value_fn: Callable[[EventT], StateType] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( + # Stats + EcovacsSensorEntityDescription[StatsEvent]( + key="stats_area", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.area, + translation_key="stats_area", + native_unit_of_measurement=AREA_SQUARE_METERS, + ), + EcovacsSensorEntityDescription[StatsEvent]( + key="stats_time", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.time, + translation_key="stats_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + # TotalStats + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.area, + key="total_stats_area", + translation_key="total_stats_area", + native_unit_of_measurement=AREA_SQUARE_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.time, + key="total_stats_time", + translation_key="total_stats_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.cleanings, + key="total_stats_cleanings", + translation_key="total_stats_cleanings", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[BatteryEvent]( + capability_fn=lambda caps: caps.battery, + value_fn=lambda e: e.value, + key=ATTR_BATTERY_LEVEL, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.ip, + key="network_ip", + translation_key="network_ip", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.rssi, + key="network_rssi", + translation_key="network_rssi", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.ssid, + key="network_ssid", + translation_key="network_ssid", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsLifespanSensorEntityDescription(SensorEntityDescription): + """Ecovacs lifespan sensor entity description.""" + + component: LifeSpan + value_fn: Callable[[LifeSpanEvent], int | float] + + +LIFESPAN_ENTITY_DESCRIPTIONS = tuple( + EcovacsLifespanSensorEntityDescription( + component=component, + value_fn=lambda e: e.percent, + key=f"lifespan_{component.name.lower()}", + translation_key=f"lifespan_{component.name.lower()}", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + for component in SUPPORTED_LIFESPANS +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsSensor, ENTITY_DESCRIPTIONS + ) + for device in controller.devices: + lifespan_capability = device.capabilities.life_span + for description in LIFESPAN_ENTITY_DESCRIPTIONS: + if description.component in lifespan_capability.types: + entities.append( + EcovacsLifespanSensor(device, lifespan_capability, description) + ) + + if capability := device.capabilities.error: + entities.append(EcovacsErrorSensor(device, capability)) + + async_add_entities(entities) + + +class EcovacsSensor( + EcovacsDescriptionEntity[CapabilityEvent], + SensorEntity, +): + """Ecovacs sensor.""" + + entity_description: EcovacsSensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: Event) -> None: + value = self.entity_description.value_fn(event) + if value is None: + return + + self._attr_native_value = value + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + +class EcovacsLifespanSensor( + EcovacsDescriptionEntity[CapabilityLifeSpan], + SensorEntity, +): + """Lifespan sensor.""" + + entity_description: EcovacsLifespanSensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: LifeSpanEvent) -> None: + if event.type == self.entity_description.component: + self._attr_native_value = self.entity_description.value_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + +class EcovacsErrorSensor( + EcovacsEntity[CapabilityEvent[ErrorEvent]], + SensorEntity, +): + """Error sensor.""" + + _always_available = True + _unrecorded_attributes = frozenset({CONF_DESCRIPTION}) + entity_description: SensorEntityDescription = SensorEntityDescription( + key="error", + translation_key="error", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: ErrorEvent) -> None: + self._attr_native_value = event.code + self._attr_extra_state_attributes = {CONF_DESCRIPTION: event.description} + + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json new file mode 100644 index 00000000000..7a456483877 --- /dev/null +++ b/homeassistant/components/ecovacs/strings.json @@ -0,0 +1,212 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "Invalid URL", + "invalid_url_schema_override_rest_url": "Invalid REST URL scheme.\nThe URL should start with `http://` or `https://`.", + "invalid_url_schema_override_mqtt_url": "Invalid MQTT URL scheme.\nThe URL should start with `mqtt://` or `mqtts://`.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "auth": { + "data": { + "country": "Country", + "override_rest_url": "REST URL", + "override_mqtt_url": "MQTT URL", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_mqtt_certificate": "Verify MQTT SSL certificate" + }, + "data_description": { + "override_rest_url": "Enter the REST URL of your self-hosted instance including the scheme (http/https).", + "override_mqtt_url": "Enter the MQTT URL of your self-hosted instance including the scheme (mqtt/mqtts)." + } + }, + "user": { + "data": { + "mode": "[%key:common::config_flow::data::mode%]" + }, + "data_description": { + "mode": "Select the mode you want to use to connect to Ecovacs. If you are unsure, select 'Cloud'.\n\nSelect 'Self-hosted' only if you have a working self-hosted instance." + } + } + } + }, + "entity": { + "binary_sensor": { + "water_mop_attached": { + "name": "Mop attached" + } + }, + "button": { + "relocate": { + "name": "Relocate" + }, + "reset_lifespan_brush": { + "name": "Reset main brush lifespan" + }, + "reset_lifespan_filter": { + "name": "Reset filter lifespan" + }, + "reset_lifespan_side_brush": { + "name": "Reset side brushes lifespan" + } + }, + "image": { + "map": { + "name": "Map" + } + }, + "number": { + "clean_count": { + "name": "Clean count" + }, + "volume": { + "name": "Volume" + } + }, + "sensor": { + "error": { + "name": "Error", + "state_attributes": { + "description": { + "name": "Description" + } + } + }, + "lifespan_brush": { + "name": "Main brush lifespan" + }, + "lifespan_filter": { + "name": "Filter lifespan" + }, + "lifespan_side_brush": { + "name": "Side brushes lifespan" + }, + "network_ip": { + "name": "IP address" + }, + "network_rssi": { + "name": "Wi-Fi RSSI" + }, + "network_ssid": { + "name": "Wi-Fi SSID" + }, + "stats_area": { + "name": "Area cleaned" + }, + "stats_time": { + "name": "Cleaning duration" + }, + "total_stats_area": { + "name": "Total area cleaned" + }, + "total_stats_cleanings": { + "name": "Total cleanings" + }, + "total_stats_time": { + "name": "Total cleaning duration" + } + }, + "select": { + "water_amount": { + "name": "Water flow level", + "state": { + "high": "High", + "low": "Low", + "medium": "Medium", + "ultrahigh": "Ultrahigh" + } + }, + "work_mode": { + "name": "Work mode", + "state": { + "mop": "Mop", + "mop_after_vacuum": "Mop after vacuum", + "vacuum": "Vacuum", + "vacuum_and_mop": "Vacuum & mop" + } + } + }, + "switch": { + "advanced_mode": { + "name": "Advanced mode" + }, + "carpet_auto_fan_boost": { + "name": "Carpet auto-boost suction" + }, + "clean_preference": { + "name": "Clean preference" + }, + "continuous_cleaning": { + "name": "Continuous cleaning" + }, + "true_detect": { + "name": "True detect" + } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "max": "Max", + "max_plus": "Max+", + "normal": "Normal", + "quiet": "Quiet" + } + }, + "rooms": { + "name": "Rooms" + } + } + } + } + }, + "exceptions": { + "vacuum_send_command_params_dict": { + "message": "Params must be a dictionary and not a list" + }, + "vacuum_send_command_params_required": { + "message": "Params are required for the command: {command}" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_country_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid country specified in the YAML configuration.\n\nPlease change the country to the [Alpha-2 code of your country]({countries_url}) and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_continent_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid continent specified in the YAML configuration.\n\nPlease correct the continent to be one of {continent_list} and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_continent_not_match": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an unexpected continent specified in the YAML configuration.\n\nFrom the given country, the continent '{continent}' is expected. Change the continent and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.\n\nIf the contintent '{continent}' is not applicable, please open an issue on [GitHub]({github_issue_url})." + } + }, + "selector": { + "installation_mode": { + "options": { + "cloud": "Cloud", + "self_hosted": "Self-hosted" + } + } + } +} diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py new file mode 100644 index 00000000000..e9e915877d8 --- /dev/null +++ b/homeassistant/components/ecovacs/switch.py @@ -0,0 +1,111 @@ +"""Ecovacs switch module.""" +from dataclasses import dataclass +from typing import Any + +from deebot_client.capabilities import CapabilitySetEnable +from deebot_client.events import EnableEvent + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSwitchEntityDescription( + SwitchEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs switch entity description.""" + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.advanced_mode, + key="advanced_mode", + translation_key="advanced_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.continuous, + key="continuous_cleaning", + translation_key="continuous_cleaning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.carpet_auto_fan_boost, + key="carpet_auto_fan_boost", + translation_key="carpet_auto_fan_boost", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.preference, + key="clean_preference", + translation_key="clean_preference", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.true_detect, + key="true_detect", + translation_key="true_detect", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSwitchEntity( + EcovacsDescriptionEntity[CapabilitySetEnable], + SwitchEntity, +): + """Ecovacs switch entity.""" + + entity_description: EcovacsSwitchEntityDescription + + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EnableEvent) -> None: + self._attr_is_on = event.enable + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._device.execute_command(self._capability.set(True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._device.execute_command(self._capability.set(False)) diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py new file mode 100644 index 00000000000..28750d4f9de --- /dev/null +++ b/homeassistant/components/ecovacs/util.py @@ -0,0 +1,38 @@ +"""Ecovacs util functions.""" +from __future__ import annotations + +import random +import string +from typing import TYPE_CHECKING + +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) + +if TYPE_CHECKING: + from .controller import EcovacsController + + +def get_client_device_id() -> str: + """Get client device id.""" + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) + ) + + +def get_supported_entitites( + controller: EcovacsController, + entity_class: type[EcovacsDescriptionEntity], + descriptions: tuple[EcovacsCapabilityEntityDescription, ...], +) -> list[EcovacsEntity]: + """Return all supported entities for all devices.""" + entities: list[EcovacsEntity] = [] + + for device in controller.devices: + for description in descriptions: + if capability := description.capability_fn(device.capabilities): + entities.append(entity_class(device, capability, description)) + + return entities diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 2ec9a1a3e4a..debd751bb79 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -1,9 +1,14 @@ """Support for Ecovacs Ecovacs Vacuums.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent +from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( @@ -11,16 +16,22 @@ from homeassistant.components.vacuum import ( STATE_DOCKED, STATE_ERROR, STATE_IDLE, + STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, + StateVacuumEntityDescription, VacuumEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from . import ECOVACS_DEVICES +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity _LOGGER = logging.getLogger(__name__) @@ -28,24 +39,25 @@ ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" -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 Ecovacs vacuums.""" - vacuums = [] - devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES] - for device in devices: + vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [] + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) + vacuums.append(EcovacsLegacyVacuum(device)) + for device in controller.devices: vacuums.append(EcovacsVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) -class EcovacsVacuum(StateVacuumEntity): - """Ecovacs Vacuums such as Deebot.""" +class EcovacsLegacyVacuum(StateVacuumEntity): + """Legacy Ecovacs vacuums.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_should_poll = False @@ -66,7 +78,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device = device vacuum = self.device.vacuum - self.error = None + self.error: str | None = None self._attr_unique_id = vacuum["did"] self._attr_name = vacuum.get("nick", vacuum["did"]) @@ -77,7 +89,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device.lifespanEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.errorEvents.subscribe(self.on_error) - def on_error(self, error): + def on_error(self, error: str) -> None: """Handle an error event from the robot. This will not change the entity's state. If the error caused the state @@ -117,7 +129,7 @@ class EcovacsVacuum(StateVacuumEntity): def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" if self.device.battery_status is not None: - return self.device.battery_status * 100 + return self.device.battery_status * 100 # type: ignore[no-any-return] return None @@ -131,7 +143,7 @@ class EcovacsVacuum(StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self.device.fan_speed + return self.device.fan_speed # type: ignore[no-any-return] @property def extra_state_attributes(self) -> dict[str, Any]: @@ -183,3 +195,178 @@ class EcovacsVacuum(StateVacuumEntity): ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) + + +_STATE_TO_VACUUM_STATE = { + State.IDLE: STATE_IDLE, + State.CLEANING: STATE_CLEANING, + State.RETURNING: STATE_RETURNING, + State.DOCKED: STATE_DOCKED, + State.ERROR: STATE_ERROR, + State.PAUSED: STATE_PAUSED, +} + +_ATTR_ROOMS = "rooms" + + +class EcovacsVacuum( + EcovacsEntity[Capabilities], + StateVacuumEntity, +): + """Ecovacs vacuum.""" + + _unrecorded_attributes = frozenset({_ATTR_ROOMS}) + + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + + entity_description = StateVacuumEntityDescription( + key="vacuum", translation_key="vacuum", name=None + ) + + def __init__(self, device: Device) -> None: + """Initialize the vacuum.""" + capabilities = device.capabilities + super().__init__(device, capabilities) + + self._rooms: list[Room] = [] + + self._attr_fan_speed_list = [ + level.display_name for level in capabilities.fan_speed.types + ] + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_battery(event: BatteryEvent) -> None: + self._attr_battery_level = event.value + self.async_write_ha_state() + + async def on_fan_speed(event: FanSpeedEvent) -> None: + self._attr_fan_speed = event.speed.display_name + self.async_write_ha_state() + + async def on_rooms(event: RoomsEvent) -> None: + self._rooms = event.rooms + self.async_write_ha_state() + + async def on_status(event: StateEvent) -> None: + self._attr_state = _STATE_TO_VACUUM_STATE[event.state] + self.async_write_ha_state() + + self._subscribe(self._capability.battery.event, on_battery) + self._subscribe(self._capability.fan_speed.event, on_fan_speed) + self._subscribe(self._capability.state.event, on_status) + + if map_caps := self._capability.map: + self._subscribe(map_caps.rooms.event, on_rooms) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes. + + Implemented by platform classes. Convention for attribute names + is lowercase snake_case. + """ + rooms: dict[str, Any] = {} + for room in self._rooms: + # convert room name to snake_case to meet the convention + room_name = slugify(room.name) + room_values = rooms.get(room_name) + if room_values is None: + rooms[room_name] = room.id + elif isinstance(room_values, list): + room_values.append(room.id) + else: + # Convert from int to list + rooms[room_name] = [room_values, room.id] + + return { + _ATTR_ROOMS: rooms, + } + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._device.execute_command(self._capability.fan_speed.set(fan_speed)) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self._device.execute_command(self._capability.charge.execute()) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + await self._clean_command(CleanAction.STOP) + + async def async_pause(self) -> None: + """Pause the vacuum cleaner.""" + await self._clean_command(CleanAction.PAUSE) + + async def async_start(self) -> None: + """Start the vacuum cleaner.""" + await self._clean_command(CleanAction.START) + + async def _clean_command(self, action: CleanAction) -> None: + await self._device.execute_command( + self._capability.clean.action.command(action) + ) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum cleaner.""" + await self._device.execute_command(self._capability.play_sound.execute()) + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner.""" + _LOGGER.debug("async_send_command %s with %s", command, params) + if params is None: + params = {} + elif isinstance(params, list): + raise ServiceValidationError( + "Params must be a dict!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_dict", + ) + + if command in ["spot_area", "custom_area"]: + if params is None: + raise ServiceValidationError( + f"Params are required for {command}!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_required", + translation_placeholders={"command": command}, + ) + + if command in "spot_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.SPOT_AREA, + str(params["rooms"]), + params.get("cleanings", 1), + ) + ) + elif command == "custom_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.CUSTOM_AREA, + str(params["coordinates"]), + params.get("cleanings", 1), + ) + ) + else: + await self._device.execute_command( + self._capability.custom.set(command, params) + ) diff --git a/homeassistant/components/ecowitt/__init__.py b/homeassistant/components/ecowitt/__init__.py index 567e21b4d87..eaf2441ffac 100644 --- a/homeassistant/components/ecowitt/__init__.py +++ b/homeassistant/components/ecowitt/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @callback - def _stop_ecowitt(_: Event): + def _stop_ecowitt(_: Event) -> None: """Stop the Ecowitt listener.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index 12fcca449c0..cf62cfb2d94 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -31,10 +31,10 @@ class EcowittEntity(Entity): sw_version=sensor.station.version, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Install listener for updates later.""" - def _update_state(): + def _update_state() -> None: """Update the state on callback.""" self.async_write_ha_state() diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 9f0f668ee81..d3dfe0331ef 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.5.0"] + "requirements": ["aioecowitt==2024.2.0"] } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 809f1c531da..dd8752dde7f 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key=CONF_CURRENT_VALUES, - name="Power Usage", + translation_key="power_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -156,7 +156,8 @@ class EfergySensor(EfergyEntity, SensorEntity): super().__init__(api, server_unique_id) self.entity_description = description if description.key == CONF_CURRENT_VALUES: - self._attr_name = f"{description.name}_{'' if sid is None else sid}" + assert sid is not None + self._attr_translation_placeholders = {"sid": str(sid)} self._attr_unique_id = ( f"{server_unique_id}/{description.key}_{'' if sid is None else sid}" ) diff --git a/homeassistant/components/efergy/strings.json b/homeassistant/components/efergy/strings.json index 3b17bf07f1a..612c964487b 100644 --- a/homeassistant/components/efergy/strings.json +++ b/homeassistant/components/efergy/strings.json @@ -48,6 +48,9 @@ }, "cost_year": { "name": "Yearly energy cost" + }, + "power_usage": { + "name": "Power usage {sid}" } } } diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 086a5288f77..9f6e7cbddf5 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -111,6 +111,7 @@ class ElectraClimateEntity(ClimateEntity): _attr_hvac_modes = ELECTRA_MODES _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" @@ -121,6 +122,8 @@ class ElectraClimateEntity(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) swing_modes: list = [] diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 5af02f69bcf..ea10cdb4dc4 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -12,8 +12,11 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import DOMAIN -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR +from .coordinator import ( + ElectricKiwiAccountDataCoordinator, + ElectricKiwiHOPDataCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT] @@ -41,14 +44,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) + account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api) try: await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() + await account_coordinator.async_config_entry_first_refresh() except ApiException as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hop_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + HOP_COORDINATOR: hop_coordinator, + ACCOUNT_COORDINATOR: account_coordinator, + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..0b455b045cf 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -9,3 +9,6 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" + +HOP_COORDINATOR = "hop_coordinator" +ACCOUNT_COORDINATOR = "account_coordinator" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index b084f4656d5..c3f49d1aba9 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -6,7 +6,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import Hop, HopIntervals +from electrickiwi_api.model import AccountBalance, Hop, HopIntervals from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -14,11 +14,38 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): + """ElectricKiwi Account Data object.""" + + def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + """Initialize ElectricKiwiAccountDataCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Electric Kiwi Account Data", + update_interval=ACCOUNT_SCAN_INTERVAL, + ) + self._ek_api = ek_api + + async def _async_update_data(self) -> AccountBalance: + """Fetch data from Account balance API endpoint.""" + try: + async with asyncio.timeout(60): + return await self._ek_api.get_account_balance() + except AuthException as auth_err: + raise ConfigEntryAuthFailed from auth_err + except ApiException as api_err: + raise UpdateFailed( + f"Error communicating with EK API: {api_err}" + ) from api_err + + class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): - """ElectricKiwi Data object.""" + """ElectricKiwi HOP Data object.""" def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index eb8aaac8c2f..5905efc1604 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN +from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR from .coordinator import ElectricKiwiHOPDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ ATTR_EK_HOP_SELECT = "hop_select" HOP_SELECT = SelectEntityDescription( entity_category=EntityCategory.CONFIG, key=ATTR_EK_HOP_SELECT, - translation_key="hopselector", + translation_key="hop_selector", ) @@ -27,7 +27,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Electric Kiwi select setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] + hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ + HOP_COORDINATOR + ] _LOGGER.debug("Setting up select entity") async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 51d02781554..4f8cc59757d 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -4,28 +4,89 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging -from electrickiwi_api.model import Hop +from electrickiwi_api.model import AccountBalance, Hop from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE 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 .const import ATTRIBUTION, DOMAIN -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR +from .coordinator import ( + ElectricKiwiAccountDataCoordinator, + ElectricKiwiHOPDataCoordinator, +) -_LOGGER = logging.getLogger(DOMAIN) +ATTR_EK_HOP_START = "hop_power_start" +ATTR_EK_HOP_END = "hop_power_end" +ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance" +ATTR_TOTAL_CURRENT_BALANCE = "total_account_balance" +ATTR_NEXT_BILLING_DATE = "next_billing_date" +ATTR_HOP_PERCENTAGE = "hop_percentage" -ATTR_EK_HOP_START = "hop_sensor_start" -ATTR_EK_HOP_END = "hop_sensor_end" + +@dataclass(frozen=True) +class ElectricKiwiAccountRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[AccountBalance], float | datetime] + + +@dataclass(frozen=True) +class ElectricKiwiAccountSensorEntityDescription( + SensorEntityDescription, ElectricKiwiAccountRequiredKeysMixin +): + """Describes Electric Kiwi sensor entity.""" + + +ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_TOTAL_RUNNING_BALANCE, + translation_key="total_running_balance", + icon="mdi:currency-usd", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=CURRENCY_DOLLAR, + value_func=lambda account_balance: float(account_balance.total_running_balance), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_TOTAL_CURRENT_BALANCE, + translation_key="total_current_balance", + icon="mdi:currency-usd", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=CURRENCY_DOLLAR, + value_func=lambda account_balance: float(account_balance.total_account_balance), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_NEXT_BILLING_DATE, + translation_key="next_billing_date", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + value_func=lambda account_balance: datetime.strptime( + account_balance.next_billing_date, "%Y-%m-%d" + ), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_HOP_PERCENTAGE, + translation_key="hop_power_savings", + icon="mdi:percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_func=lambda account_balance: float( + account_balance.connections[0].hop_percentage + ), + ), +) @dataclass(frozen=True) @@ -65,13 +126,13 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_START, - translation_key="hopfreepowerstart", + translation_key="hop_free_power_start", device_class=SensorDeviceClass.TIMESTAMP, value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time), ), ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_END, - translation_key="hopfreepowerend", + translation_key="hop_free_power_end", device_class=SensorDeviceClass.TIMESTAMP, value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time), ), @@ -81,13 +142,58 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Electric Kiwi Sensor Setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] - hop_entities = [ - ElectricKiwiHOPEntity(hop_coordinator, description) - for description in HOP_SENSOR_TYPES + """Electric Kiwi Sensors Setup.""" + account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][ACCOUNT_COORDINATOR] + + entities: list[SensorEntity] = [ + ElectricKiwiAccountEntity( + account_coordinator, + description, + ) + for description in ACCOUNT_SENSOR_TYPES ] - async_add_entities(hop_entities) + + hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ + HOP_COORDINATOR + ] + entities.extend( + [ + ElectricKiwiHOPEntity(hop_coordinator, description) + for description in HOP_SENSOR_TYPES + ] + ) + async_add_entities(entities) + + +class ElectricKiwiAccountEntity( + CoordinatorEntity[ElectricKiwiAccountDataCoordinator], SensorEntity +): + """Entity object for Electric Kiwi sensor.""" + + entity_description: ElectricKiwiAccountSensorEntityDescription + _attr_has_entity_name = True + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: ElectricKiwiAccountDataCoordinator, + description: ElectricKiwiAccountSensorEntityDescription, + ) -> None: + """Entity object for Electric Kiwi sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) + self.entity_description = description + + @property + def native_value(self) -> float | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) class ElectricKiwiHOPEntity( diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index d21c0d80ca6..359ca8e367d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -28,9 +28,25 @@ }, "entity": { "sensor": { - "hopfreepowerstart": { "name": "Hour of free power start" }, - "hopfreepowerend": { "name": "Hour of free power end" } + "hop_free_power_start": { + "name": "Hour of free power start" + }, + "hop_free_power_end": { + "name": "Hour of free power end" + }, + "total_running_balance": { + "name": "Total running balance" + }, + "total_current_balance": { + "name": "Total current balance" + }, + "next_billing_date": { + "name": "Next billing date" + }, + "hop_power_savings": { + "name": "Hour of power savings" + } }, - "select": { "hopselector": { "name": "Hour of free power" } } + "select": { "hop_selector": { "name": "Hour of free power" } } } } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 0671a7adb1d..c68902560b9 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.1"], + "requirements": ["elgato==5.1.2"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index c1e6dc7b034..97b16b14954 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -79,6 +79,8 @@ class ElkThermostat(ElkEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_min_temp = 1 _attr_max_temp = 99 @@ -87,6 +89,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 440344fb839..7cbc6f63596 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -34,7 +34,7 @@ from .const import DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) -class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): +class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=hass-enforce-coordinator-module """Coordinator helper to handle Elmax API polling.""" def __init__( diff --git a/homeassistant/components/elvia/__init__.py b/homeassistant/components/elvia/__init__.py new file mode 100644 index 00000000000..1f85fe720a7 --- /dev/null +++ b/homeassistant/components/elvia/__init__.py @@ -0,0 +1,49 @@ +"""The Elvia integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from elvia import error as ElviaError + +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_METERING_POINT_ID, LOGGER +from .importer import ElviaImporter + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Elvia from a config entry.""" + importer = ElviaImporter( + hass=hass, + api_token=entry.data[CONF_API_TOKEN], + metering_point_id=entry.data[CONF_METERING_POINT_ID], + ) + + async def _import_meter_values(_: datetime | None = None) -> None: + """Import meter values.""" + try: + await importer.import_meter_values() + except ElviaError.ElviaException as exception: + LOGGER.exception("Unknown error %s", exception) + + try: + await importer.import_meter_values() + except ElviaError.ElviaException as exception: + LOGGER.exception("Unknown error %s", exception) + return False + + entry.async_on_unload( + async_track_time_interval( + hass, + _import_meter_values, + timedelta(minutes=60), + ) + ) + + return True diff --git a/homeassistant/components/elvia/config_flow.py b/homeassistant/components/elvia/config_flow.py new file mode 100644 index 00000000000..fb50842e39b --- /dev/null +++ b/homeassistant/components/elvia/config_flow.py @@ -0,0 +1,121 @@ +"""Config flow for Elvia integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Any + +from elvia import Elvia, error as ElviaError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.util import dt as dt_util + +from .const import CONF_METERING_POINT_ID, DOMAIN, LOGGER + +if TYPE_CHECKING: + from homeassistant.data_entry_flow import FlowResult + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Elvia.""" + + def __init__(self) -> None: + """Initialize.""" + self._api_token: str | None = None + self._metering_point_ids: list[str] | None = None + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._api_token = api_token = user_input[CONF_API_TOKEN] + client = Elvia(meter_value_token=api_token).meter_value() + try: + end_time = dt_util.utcnow() + results = await client.get_meter_values( + start_time=(end_time - timedelta(hours=1)).isoformat(), + end_time=end_time.isoformat(), + ) + + except ElviaError.AuthError as exception: + LOGGER.error("Authentication error %s", exception) + errors["base"] = "invalid_auth" + except ElviaError.ElviaException as exception: + LOGGER.error("Unknown error %s", exception) + errors["base"] = "unknown" + else: + try: + self._metering_point_ids = metering_point_ids = [ + x["meteringPointId"] for x in results["meteringpoints"] + ] + except KeyError: + return self.async_abort(reason="no_metering_points") + + if (meter_count := len(metering_point_ids)) > 1: + return await self.async_step_select_meter() + if meter_count == 1: + return await self._create_config_entry( + api_token=api_token, + metering_point_id=metering_point_ids[0], + ) + + return self.async_abort(reason="no_metering_points") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } + ), + errors=errors, + ) + + async def async_step_select_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle selecting a metering point ID.""" + if TYPE_CHECKING: + assert self._metering_point_ids is not None + assert self._api_token is not None + + if user_input is not None: + return await self._create_config_entry( + api_token=self._api_token, + metering_point_id=user_input[CONF_METERING_POINT_ID], + ) + + return self.async_show_form( + step_id="select_meter", + data_schema=vol.Schema( + { + vol.Required( + CONF_METERING_POINT_ID, + default=self._metering_point_ids[0], + ): vol.In(self._metering_point_ids), + } + ), + ) + + async def _create_config_entry( + self, + api_token: str, + metering_point_id: str, + ) -> FlowResult: + """Store metering point ID and API token.""" + if (await self.async_set_unique_id(metering_point_id)) is not None: + return self.async_abort( + reason="metering_point_id_already_configured", + description_placeholders={"metering_point_id": metering_point_id}, + ) + return self.async_create_entry( + title=metering_point_id, + data={ + CONF_API_TOKEN: api_token, + CONF_METERING_POINT_ID: metering_point_id, + }, + ) diff --git a/homeassistant/components/elvia/const.py b/homeassistant/components/elvia/const.py new file mode 100644 index 00000000000..c4b8e40e73f --- /dev/null +++ b/homeassistant/components/elvia/const.py @@ -0,0 +1,7 @@ +"""Constants for the Elvia integration.""" +from logging import getLogger + +DOMAIN = "elvia" +LOGGER = getLogger(__package__) + +CONF_METERING_POINT_ID = "metering_point_id" diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py new file mode 100644 index 00000000000..097db51cab8 --- /dev/null +++ b/homeassistant/components/elvia/importer.py @@ -0,0 +1,155 @@ +"""Importer for the Elvia integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, cast + +from elvia import Elvia, error as ElviaError + +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.components.recorder.util import get_instance +from homeassistant.const import UnitOfEnergy +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from elvia.types.meter_value_types import MeterValueTimeSeries + + from homeassistant.core import HomeAssistant + + +class ElviaImporter: + """Class to import data from Elvia.""" + + def __init__( + self, + hass: HomeAssistant, + api_token: str, + metering_point_id: str, + ) -> None: + """Initialize.""" + self.hass = hass + self.client = Elvia(meter_value_token=api_token).meter_value() + self.metering_point_id = metering_point_id + + async def _fetch_hourly_data( + self, + since: datetime, + until: datetime, + ) -> list[MeterValueTimeSeries]: + """Fetch hourly data.""" + start_time = since.isoformat() + end_time = until.isoformat() + LOGGER.debug("Fetching hourly data %s - %s", start_time, end_time) + all_data = await self.client.get_meter_values( + start_time=start_time, + end_time=end_time, + metering_point_ids=[self.metering_point_id], + ) + return all_data["meteringpoints"][0]["metervalue"]["timeSeries"] + + async def import_meter_values(self) -> None: + """Import meter values.""" + statistics: list[StatisticData] = [] + statistic_id = f"{DOMAIN}:{self.metering_point_id}_consumption" + last_stats = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 1, + statistic_id, + True, + {"sum"}, + ) + + if not last_stats: + # First time we insert 3 years of data (if available) + hourly_data: list[MeterValueTimeSeries] = [] + until = dt_util.utcnow() + for year in (3, 2, 1): + try: + year_hours = await self._fetch_hourly_data( + since=until - timedelta(days=365 * year), + until=until - timedelta(days=365 * (year - 1)), + ) + except ElviaError.ElviaException: + # This will raise if the contract have no data for the + # year, we can safely ignore this + continue + hourly_data.extend(year_hours) + + if hourly_data is None or len(hourly_data) == 0: + LOGGER.error("No data available for the metering point") + return + last_stats_time = None + _sum = 0.0 + else: + try: + hourly_data = await self._fetch_hourly_data( + since=dt_util.utc_from_timestamp( + last_stats[statistic_id][0]["end"] + ), + until=dt_util.utcnow(), + ) + except ElviaError.ElviaException as err: + LOGGER.error("Error fetching data: %s", err) + return + + if ( + hourly_data is None + or len(hourly_data) == 0 + or not hourly_data[-1]["verified"] + or (from_time := dt_util.parse_datetime(hourly_data[0]["startTime"])) + is None + ): + return + + curr_stat = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + from_time - timedelta(hours=1), + None, + {statistic_id}, + "hour", + None, + {"sum"}, + ) + first_stat = curr_stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] + + last_stats_time_dt = ( + dt_util.utc_from_timestamp(last_stats_time) if last_stats_time else None + ) + + for entry in hourly_data: + from_time = dt_util.parse_datetime(entry["startTime"]) + if from_time is None or ( + last_stats_time_dt is not None and from_time <= last_stats_time_dt + ): + continue + + _sum += entry["value"] + + statistics.append( + StatisticData(start=from_time, state=entry["value"], sum=_sum) + ) + + async_add_external_statistics( + hass=self.hass, + metadata=StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{self.metering_point_id} Consumption", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + statistics=statistics, + ) + LOGGER.debug("Imported %s statistics", len(statistics)) diff --git a/homeassistant/components/elvia/manifest.json b/homeassistant/components/elvia/manifest.json new file mode 100644 index 00000000000..abb4f846f00 --- /dev/null +++ b/homeassistant/components/elvia/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "elvia", + "name": "Elvia", + "codeowners": ["@ludeeus"], + "config_flow": true, + "dependencies": ["recorder"], + "documentation": "https://www.home-assistant.io/integrations/elvia", + "iot_class": "cloud_polling", + "requirements": ["elvia==0.1.0"] +} diff --git a/homeassistant/components/elvia/strings.json b/homeassistant/components/elvia/strings.json new file mode 100644 index 00000000000..888a5ab8e76 --- /dev/null +++ b/homeassistant/components/elvia/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your meter value API token from Elvia", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + }, + "select_meter": { + "data": { + "metering_point_id": "Select your metering point ID" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.", + "no_metering_points": "The provived API token has no metering points." + } + } +} diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 6e196eebeb0..1c3011ee28d 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -15,7 +15,7 @@ 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 homeassistant.helpers.typing import UNDEFINED, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -27,10 +27,12 @@ from .const import DOMAIN 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 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): 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}" - if description.name is not UNDEFINED: - self._attr_name = f"{label} {description.name}" + label = self.channel_data.label or str(channel_number) + if description.translation_key is not None: + 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 08ffe030890..95f7f65bb98 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -22,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_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index 1d233c9ed81..3559c0da99b 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -155,7 +155,7 @@ class EmulatedRoku: ) # start immediately if already running - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await emulated_roku_start(None) else: self._unsub_start_listener = self.hass.bus.async_listen_once( diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index d8e548c22f8..325c443375e 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -109,7 +109,8 @@ def __get_coordinator( }, ) - return hass.data[DOMAIN][entry_id] + coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + return coordinator async def __get_prices( diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index e4283eeef9d..032669499d1 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -3,8 +3,9 @@ from __future__ import annotations from aiohttp.client_exceptions import ClientConnectorError from openwebif.api import OpenWebIfDevice -from openwebif.enums import RemoteControlCodes +from openwebif.enums import RemoteControlCodes, SetVolumeOption import voluptuous as vol +from yarl import URL from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -22,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -88,12 +90,18 @@ async def async_setup_platform( config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - device = OpenWebIfDevice( + base_url = URL.build( + scheme="https" if config[CONF_SSL] else "http", host=config[CONF_HOST], port=config.get(CONF_PORT), - username=config.get(CONF_USERNAME), + user=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), - is_https=config[CONF_SSL], + ) + + session = async_create_clientsession(hass, verify_ssl=False, base_url=base_url) + + device = OpenWebIfDevice( + host=session, turn_off_to_deep=config.get(CONF_DEEP_STANDBY), source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) @@ -101,7 +109,6 @@ async def async_setup_platform( try: about = await device.get_about() except ClientConnectorError as err: - await device.close() raise PlatformNotReady from err async_add_entities([Enigma2Device(config[CONF_NAME], device, about)]) @@ -148,15 +155,11 @@ class Enigma2Device(MediaPlayerEntity): async def async_volume_up(self) -> None: """Volume up the media player.""" - if self._attr_volume_level is None: - return - await self._device.set_volume(int(self._attr_volume_level * 100) + 5) + await self._device.set_volume(SetVolumeOption.UP) async def async_volume_down(self) -> None: """Volume down media player.""" - if self._attr_volume_level is None: - return - await self._device.set_volume(int(self._attr_volume_level * 100) - 5) + await self._device.set_volume(SetVolumeOption.DOWN) async def async_media_stop(self) -> None: """Send stop command.""" diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 08e9b5ba11d..f9c522393d7 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -15,8 +15,8 @@ SIGNAL_SEND_MESSAGE = "enocean.send_message" LOGGER = logging.getLogger(__package__) PLATFORMS = [ - Platform.LIGHT, Platform.BINARY_SENSOR, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 999542ee2a5..5921de15bde 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -42,12 +42,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize an envoy flow.""" - self.ip_address = None + self.ip_address: str | None = None self.username = None self.protovers: str | None = None - self._reauth_entry = None + self._reauth_entry: config_entries.ConfigEntry | None = None @callback def _async_generate_schema(self) -> vol.Schema: @@ -103,13 +103,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and entry.data[CONF_HOST] == self.ip_address ): title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY - self.hass.config_entries.async_update_entry( - entry, title=title, unique_id=serial + return self.async_update_reload_and_abort( + entry, title=title, unique_id=serial, reason="already_configured" ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - return self.async_abort(reason="already_configured") return await self.async_step_user() @@ -164,16 +160,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = self._async_envoy_name() if self._reauth_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( 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") if not self.unique_id: await self.async_set_unique_id(envoy.serial_number) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 4b3a4eadb3d..61c8a07cfbb 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.17.0"], + "requirements": ["pyenphase==1.19.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2ae9dca63ba..c2ecf8e8a13 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import datetime import logging +from typing import TYPE_CHECKING from pyenphase import ( EnvoyEncharge, @@ -15,6 +16,7 @@ from pyenphase import ( EnvoySystemConsumption, EnvoySystemProduction, ) +from pyenphase.const import PHASENAMES, PhaseNames from homeassistant.components.sensor import ( SensorDeviceClass, @@ -85,6 +87,7 @@ class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -104,6 +107,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda production: production.watts_now, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="daily_production", @@ -114,6 +118,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda production: production.watt_hours_today, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="seven_days_production", @@ -123,6 +128,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda production: production.watt_hours_last_7_days, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="lifetime_production", @@ -133,15 +139,32 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda production: production.watt_hours_lifetime, + on_phase=None, ), ) +PRODUCTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(PRODUCTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -161,6 +184,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda consumption: consumption.watts_now, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="daily_consumption", @@ -171,6 +195,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda consumption: consumption.watt_hours_today, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="seven_days_consumption", @@ -180,6 +205,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda consumption: consumption.watt_hours_last_7_days, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="lifetime_consumption", @@ -190,10 +216,26 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda consumption: consumption.watt_hours_lifetime, + on_phase=None, ), ) +CONSUMPTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(CONSUMPTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" @@ -361,6 +403,23 @@ async def async_setup_entry( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) + # For each production phase reported add production entities + if envoy_data.system_production_phases: + entities.extend( + EnvoyProductionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_production_phases.items() + for description in PRODUCTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + # For each consumption phase reported add consumption entities + if envoy_data.system_consumption_phases: + entities.extend( + EnvoyConsumptionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_consumption_phases.items() + for description in CONSUMPTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + if envoy_data.inverters: entities.extend( EnvoyInverterEntity(coordinator, description, inverter) @@ -414,9 +473,11 @@ class EnvoySystemSensorEntity(EnvoySensorBaseEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.envoy_serial_num)}, manufacturer="Enphase", - model=coordinator.envoy.part_number or "Envoy", + model=coordinator.envoy.envoy_model, name=coordinator.name, sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, ) @@ -446,6 +507,48 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_consumption) +class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase production entity.""" + + entity_description: EnvoyProductionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_production_phases + + if ( + system_production := self.data.system_production_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_production) + + +class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_consumption_phases + + if ( + system_consumption := self.data.system_consumption_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_consumption) + + class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index fe32002e6b2..f3e78432f90 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -119,6 +119,30 @@ "lifetime_consumption": { "name": "Lifetime energy consumption" }, + "current_power_production_phase": { + "name": "Current power production {phase_name}" + }, + "daily_production_phase": { + "name": "Energy production today {phase_name}" + }, + "seven_days_production_phase": { + "name": "Energy production last seven days {phase_name}" + }, + "lifetime_production_phase": { + "name": "Lifetime energy production {phase_name}" + }, + "current_power_consumption_phase": { + "name": "Current power consumption {phase_name}" + }, + "daily_consumption_phase": { + "name": "Energy consumption today {phase_name}" + }, + "seven_days_consumption_phase": { + "name": "Energy consumption last seven days {phase_name}" + }, + "lifetime_consumption_phase": { + "name": "Lifetime energy consumption {phase_name}" + }, "reserve_soc": { "name": "Reserve battery level" }, diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 76c73914db6..921c5601dac 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -169,12 +169,12 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) await self.coordinator.async_request_refresh() @@ -217,12 +217,12 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): self.async_write_ha_state() @@ -261,12 +261,12 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 14fb3e8e54c..925bc42a930 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -99,7 +99,7 @@ def device_info(config_entry: ConfigEntry) -> DeviceInfo: ) -class ECDataUpdateCoordinator(DataUpdateCoordinator): +class ECDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching EC data.""" def __init__(self, hass, ec_data, name, update_interval): diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 3735b4d16c2..047b9234b82 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -83,6 +83,7 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ember, zone): """Initialize the thermostat.""" @@ -100,6 +101,9 @@ class EphEmberThermostat(ClimateEntity): if self._hot_water: self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py new file mode 100644 index 00000000000..ed2f5559f32 --- /dev/null +++ b/homeassistant/components/epion/__init__.py @@ -0,0 +1,32 @@ +"""The Epion integration.""" +from __future__ import annotations + +from epion import Epion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import EpionCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Epion coordinator from a config entry.""" + api = Epion(entry.data[CONF_API_KEY]) + coordinator = EpionCoordinator(hass, 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 Epion config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/epion/config_flow.py b/homeassistant/components/epion/config_flow.py new file mode 100644 index 00000000000..7c89df94519 --- /dev/null +++ b/homeassistant/components/epion/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Epion.""" +from __future__ import annotations + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class EpionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Epion.""" + + VERSION = 1 + + 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: + api = Epion(user_input[CONF_API_KEY]) + try: + api_data = await self.hass.async_add_executor_job(api.get_current) + except EpionAuthenticationError: + errors["base"] = "invalid_auth" + except EpionConnectionError: + _LOGGER.error("Unexpected problem when configuring Epion API") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(api_data["accountId"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Epion integration", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/epion/const.py b/homeassistant/components/epion/const.py new file mode 100644 index 00000000000..83f82261583 --- /dev/null +++ b/homeassistant/components/epion/const.py @@ -0,0 +1,5 @@ +"""Constants for the Epion API.""" +from datetime import timedelta + +DOMAIN = "epion" +REFRESH_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py new file mode 100644 index 00000000000..3eb7efb5dc7 --- /dev/null +++ b/homeassistant/components/epion/coordinator.py @@ -0,0 +1,45 @@ +"""The Epion data coordinator.""" + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REFRESH_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Epion data update coordinator.""" + + def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + """Initialize the Epion coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Epion", + update_interval=REFRESH_INTERVAL, + ) + self.epion_api = epion_api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Epion API and construct a dictionary with device IDs as keys.""" + try: + response = await self.hass.async_add_executor_job( + self.epion_api.get_current + ) + except EpionAuthenticationError as err: + _LOGGER.error("Authentication error with Epion API") + raise ConfigEntryAuthFailed from err + except EpionConnectionError as err: + _LOGGER.error("Epion API connection problem") + raise UpdateFailed(f"Error communicating with API: {err}") from err + device_data = {} + for epion_device in response["devices"]: + device_data[epion_device["deviceId"]] = epion_device + return device_data diff --git a/homeassistant/components/epion/manifest.json b/homeassistant/components/epion/manifest.json new file mode 100644 index 00000000000..a1b8497f7e2 --- /dev/null +++ b/homeassistant/components/epion/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "epion", + "name": "Epion", + "codeowners": ["@lhgravendeel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/epion", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["epion"], + "requirements": ["epion==0.0.3"] +} diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py new file mode 100644 index 00000000000..c722e73ac6c --- /dev/null +++ b/homeassistant/components/epion/sensor.py @@ -0,0 +1,113 @@ +"""Support for Epion API.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfPressure, + 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 DOMAIN +from .coordinator import EpionCoordinator + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="co2", + suggested_display_precision=0, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + key="temperature", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + key="humidity", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.HPA, + key="pressure", + suggested_display_precision=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add an Epion entry.""" + coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EpionSensor(coordinator, epion_device_id, description) + for epion_device_id in coordinator.data + for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): + """Representation of an Epion Air sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EpionCoordinator, + epion_device_id: str, + description: SensorEntityDescription, + ) -> None: + """Initialize an EpionSensor.""" + super().__init__(coordinator) + self._epion_device_id = epion_device_id + self.entity_description = description + self._attr_unique_id = f"{epion_device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, epion_device_id)}, + manufacturer="Epion", + name=self.device.get("deviceName"), + sw_version=self.device.get("fwVersion"), + model="Epion Air", + ) + + @property + def native_value(self) -> float | None: + """Return the value reported by the sensor, or None if the relevant sensor can't produce a current measurement.""" + return self.device.get(self.entity_description.key) + + @property + def available(self) -> bool: + """Return the availability of the device that provides this sensor data.""" + return super().available and self._epion_device_id in self.coordinator.data + + @property + def device(self) -> dict[str, Any]: + """Get the device record from the current coordinator data, or None if there is no data being returned for this device ID anymore.""" + return self.coordinator.data[self._epion_device_id] diff --git a/homeassistant/components/epion/strings.json b/homeassistant/components/epion/strings.json new file mode 100644 index 00000000000..f8ef9de230c --- /dev/null +++ b/homeassistant/components/epion/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 71c8a403f8f..021cfd26764 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -82,10 +82,14 @@ class ControllerEntity(ClimateEntity): _attr_precision = PRECISION_WHOLE _attr_should_poll = False _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 6f3f903f248..e4f44dfd1fd 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -89,17 +89,17 @@ class EsphomeAlarmControlPanel( super()._on_static_info_update(static_info) static_info = self._static_info feature = 0 - if self._static_info.supported_features & EspHomeACPFeatures.ARM_HOME: + if static_info.supported_features & EspHomeACPFeatures.ARM_HOME: feature |= AlarmControlPanelEntityFeature.ARM_HOME - if self._static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: + if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: feature |= AlarmControlPanelEntityFeature.ARM_AWAY - if self._static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: + if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: feature |= AlarmControlPanelEntityFeature.ARM_NIGHT - if self._static_info.supported_features & EspHomeACPFeatures.TRIGGER: + if static_info.supported_features & EspHomeACPFeatures.TRIGGER: feature |= AlarmControlPanelEntityFeature.TRIGGER - if self._static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: + if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - if self._static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: + if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: feature |= AlarmControlPanelEntityFeature.ARM_VACATION self._attr_supported_features = AlarmControlPanelEntityFeature(feature) self._attr_code_format = ( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 08ed2f1109d..9c2177800f3 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -137,6 +137,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" + _enable_turn_on_off_backwards_compatibility = False @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -167,11 +168,11 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti 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: + if static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE else: features |= ClimateEntityFeature.TARGET_TEMPERATURE - if self._static_info.supports_target_humidity: + if static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE @@ -179,6 +180,8 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.FAN_MODE if self.swing_modes: features |= ClimateEntityFeature.SWING_MODE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON self._attr_supported_features = features def _get_precision(self) -> float: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 898fb55a3ac..9962b9144ea 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -275,16 +275,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, } if self._reauth_entry: - entry = self._reauth_entry - self.hass.config_entries.async_update_entry( - entry, data=self._reauth_entry.data | config_data + return self.async_update_reload_and_abort( + self._reauth_entry, data=self._reauth_entry.data | config_data ) - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") assert self._name is not None return self.async_create_entry( diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 41b0617e630..03264291d8f 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -28,6 +28,8 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" 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.""" @@ -156,7 +158,7 @@ async def async_set_dashboard_info( await manager.async_set_dashboard_info(addon_slug, host, port) -class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): +class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): # pylint: disable=hass-enforce-coordinator-module """Class to interact with the ESPHome dashboard.""" def __init__( @@ -177,22 +179,20 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): 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/entity.py b/homeassistant/components/esphome/entity.py index 1def6d37e02..14602077a94 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -37,6 +37,51 @@ _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) +@callback +def async_static_info_updated( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + platform: entity_platform.EntityPlatform, + async_add_entities: AddEntitiesCallback, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], + infos: list[EntityInfo], +) -> None: + """Update entities of this platform when entities are listed.""" + current_infos = entry_data.info[info_type] + new_infos: dict[int, EntityInfo] = {} + add_entities: list[_EntityT] = [] + + for info in infos: + if not current_infos.pop(info.key, None): + # Create new entity + entity = entity_type(entry_data, platform.domain, info, state_type) + add_entities.append(entity) + new_infos[info.key] = info + + # Anything still in current_infos is now gone + if current_infos: + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None + hass.async_create_task( + entry_data.async_remove_entities( + hass, current_infos.values(), device_info.mac_address + ) + ) + + # Then update the actual info + entry_data.info[info_type] = new_infos + + if new_infos: + entry_data.async_update_entity_infos(new_infos.values()) + + if add_entities: + # Add entities to Home Assistant + async_add_entities(add_entities) + + async def platform_async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -55,39 +100,21 @@ async def platform_async_setup_entry( entry_data.info[info_type] = {} entry_data.state.setdefault(state_type, {}) platform = entity_platform.async_get_current_platform() - - @callback - def async_list_entities(infos: list[EntityInfo]) -> None: - """Update entities of this platform when entities are listed.""" - current_infos = entry_data.info[info_type] - new_infos: dict[int, EntityInfo] = {} - add_entities: list[_EntityT] = [] - - for info in infos: - if not current_infos.pop(info.key, None): - # Create new entity - entity = entity_type(entry_data, platform.domain, info, state_type) - add_entities.append(entity) - new_infos[info.key] = info - - # Anything still in current_infos is now gone - if current_infos: - hass.async_create_task( - entry_data.async_remove_entities(current_infos.values()) - ) - - # Then update the actual info - entry_data.info[info_type] = new_infos - - if new_infos: - entry_data.async_update_entity_infos(new_infos.values()) - - if add_entities: - # Add entities to Home Assistant - async_add_entities(add_entities) - + on_static_info_update = functools.partial( + async_static_info_updated, + hass, + entry_data, + platform, + async_add_entities, + info_type, + entity_type, + state_type, + ) entry_data.cleanup_callbacks.append( - entry_data.async_register_static_info_callback(info_type, async_list_entities) + entry_data.async_register_static_info_callback( + info_type, + on_static_info_update, + ) ) @@ -145,7 +172,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): state_type: type[_StateT], ) -> None: """Initialize.""" - self._entry_data = entry_data self._on_entry_data_changed() self._key = entity_info.key @@ -157,7 +183,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - self._entry_id = entry_data.entry_id # # If `friendly_name` is set, we use the Friendly naming rules, if # `friendly_name` is not set we make an exception to the naming rules for @@ -183,10 +208,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): entry_data = self._entry_data hass = self.hass key = self._key + static_info = self._static_info self.async_on_remove( entry_data.async_register_key_static_info_remove_callback( - self._static_info, + static_info, functools.partial(self.async_remove, force_remove=True), ) ) @@ -204,7 +230,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) self.async_on_remove( entry_data.async_register_key_static_info_updated_callback( - self._static_info, self._on_static_info_update + static_info, self._on_static_info_update ) ) self._update_state_from_entry_data() @@ -236,12 +262,10 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): @callback def _update_state_from_entry_data(self) -> None: """Update state from entry data.""" - state = self._entry_data.state key = self._key state_type = self._state_type - has_state = key in state[state_type] - if has_state: + if has_state := key in state[state_type]: self._state = cast(_StateT, state[state_type][key]) self._has_state = has_state @@ -301,13 +325,11 @@ class EsphomeAssistEntity(Entity): connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - @callback - def _update(self) -> None: - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: """Register update callback.""" await super().async_added_to_hass() self.async_on_remove( - self._entry_data.async_subscribe_assist_pipeline_update(self._update) + self._entry_data.async_subscribe_assist_pipeline_update( + self.async_write_ha_state + ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d9e5b199748..940b1560ba4 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field +from functools import partial import logging from typing import TYPE_CHECKING, Any, Final, TypedDict, cast @@ -163,11 +164,18 @@ class RuntimeEntryData: """Register to receive callbacks when static info changes for an EntityInfo type.""" callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks.append(callback_) + return partial( + self._async_unsubscribe_register_static_info, callbacks, callback_ + ) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_register_static_info( + self, + callbacks: list[Callable[[list[EntityInfo]], None]], + callback_: Callable[[list[EntityInfo]], None], + ) -> None: + """Unsubscribe to when static info is registered.""" + callbacks.remove(callback_) @callback def async_register_key_static_info_remove_callback( @@ -179,11 +187,16 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_remove_callbacks.setdefault(callback_key, []) callbacks.append(callback_) + return partial(self._async_unsubscribe_static_key_remove, callbacks, callback_) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_static_key_remove( + self, + callbacks: list[Callable[[], Coroutine[Any, Any, None]]], + callback_: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + """Unsubscribe to when static info is removed.""" + callbacks.remove(callback_) @callback def async_register_key_static_info_updated_callback( @@ -195,11 +208,18 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) + return partial( + self._async_unsubscribe_static_key_info_updated, callbacks, callback_ + ) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_static_key_info_updated( + self, + callbacks: list[Callable[[EntityInfo], None]], + callback_: Callable[[EntityInfo], None], + ) -> None: + """Unsubscribe to when static info is updated .""" + callbacks.remove(callback_) @callback def async_set_assist_pipeline_state(self, state: bool) -> None: @@ -208,19 +228,33 @@ class RuntimeEntryData: for update_callback in self.assist_pipeline_update_callbacks: update_callback() + @callback def async_subscribe_assist_pipeline_update( self, update_callback: Callable[[], None] ) -> Callable[[], None]: """Subscribe to assist pipeline updates.""" - - def _unsubscribe() -> None: - self.assist_pipeline_update_callbacks.remove(update_callback) - self.assist_pipeline_update_callbacks.append(update_callback) - return _unsubscribe + return partial(self._async_unsubscribe_assist_pipeline_update, update_callback) - async def async_remove_entities(self, static_infos: Iterable[EntityInfo]) -> None: + @callback + def _async_unsubscribe_assist_pipeline_update( + self, update_callback: Callable[[], None] + ) -> None: + """Unsubscribe to assist pipeline updates.""" + self.assist_pipeline_update_callbacks.remove(update_callback) + + async def async_remove_entities( + self, hass: HomeAssistant, static_infos: Iterable[EntityInfo], mac: str + ) -> None: """Schedule the removal of an entity.""" + # Remove from entity registry first so the entity is fully removed + ent_reg = er.async_get(hass) + for info in static_infos: + if entry := ent_reg.async_get_entity_id( + INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info) + ): + ent_reg.async_remove(entry) + callbacks: list[Coroutine[Any, Any, None]] = [] for static_info in static_infos: callback_key = (type(static_info), static_info.key) @@ -232,19 +266,16 @@ class RuntimeEntryData: @callback def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None: """Call static info updated callbacks.""" + callbacks = self.entity_info_key_updated_callbacks for static_info in static_infos: - callback_key = (type(static_info), static_info.key) - for callback_ in self.entity_info_key_updated_callbacks.get( - callback_key, [] - ): + for callback_ in callbacks.get((type(static_info), static_info.key), ()): callback_(static_info) async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[Platform] ) -> None: async with self.platform_load_lock: - needed = platforms - self.loaded_platforms - if needed: + if needed := platforms - self.loaded_platforms: await hass.config_entries.async_forward_entry_setups(entry, needed) self.loaded_platforms |= needed @@ -305,12 +336,16 @@ class RuntimeEntryData: entity_callback: Callable[[], None], ) -> Callable[[], None]: """Subscribe to state updates.""" + subscription_key = (state_type, state_key) + self.state_subscriptions[subscription_key] = entity_callback + return partial(self._async_unsubscribe_state_update, subscription_key) - def _unsubscribe() -> None: - self.state_subscriptions.pop((state_type, state_key)) - - self.state_subscriptions[(state_type, state_key)] = entity_callback - return _unsubscribe + @callback + def _async_unsubscribe_state_update( + self, subscription_key: tuple[type[EntityState], int] + ) -> None: + """Unsubscribe to state updates.""" + self.state_subscriptions.pop(subscription_key) @callback def async_update_state(self, state: EntityState) -> None: diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 08135e1a702..4c44134374a 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -53,10 +53,7 @@ _FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection, str] = EsphomeEnumMapper( class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """A fan implementation for ESPHome.""" - @property - def _supports_speed_levels(self) -> bool: - api_version = self._api_version - return api_version.major == 1 and api_version.minor > 3 + _supports_speed_levels: bool = True async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -129,13 +126,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): (1, self._static_info.supported_speed_levels), self._state.speed_level ) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - if not self._supports_speed_levels: - return len(ORDERED_NAMED_FAN_SPEEDS) - return self._static_info.supported_speed_levels - @property @esphome_state_property def oscillating(self) -> bool | None: @@ -154,16 +144,14 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """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.""" super()._on_static_info_update(static_info) static_info = self._static_info + api_version = self._api_version + supports_speed_levels = api_version.major == 1 and api_version.minor > 3 + self._supports_speed_levels = supports_speed_levels flags = FanEntityFeature(0) if static_info.supports_oscillation: flags |= FanEntityFeature.OSCILLATE @@ -174,3 +162,8 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if static_info.supported_preset_modes: flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags + self._attr_preset_modes = static_info.supported_preset_modes + if not supports_speed_levels: + self._attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) + else: + self._attr_speed_count = static_info.supported_speed_levels diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index e170d8b3948..2771e0ccc6b 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 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 @@ -388,12 +402,24 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_supported_features = flags supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + + # If we don't know the supported color modes, ESPHome lights + # are always at least ONOFF so we can safely discard UNKNOWN + supported.discard(ColorMode.UNKNOWN) + if ColorMode.ONOFF in supported and len(supported) > 1: supported.remove(ColorMode.ONOFF) if ColorMode.BRIGHTNESS in supported and len(supported) > 1: supported.remove(ColorMode.BRIGHTNESS) if ColorMode.WHITE in supported and len(supported) == 1: supported.remove(ColorMode.WHITE) + + # If we don't know the supported color modes, its a very old + # legacy device, and since ESPHome lights are always at least ONOFF + # we can safely assume that it supports ONOFF + if not supported: + supported.add(ColorMode.ONOFF) + self._attr_supported_color_modes = supported self._attr_effect_list = static_info.effects self._attr_min_mireds = round(static_info.min_mireds) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b4ae1a1d0ad..59f37d3a078 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine +from functools import partial import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -202,14 +203,19 @@ class ESPHomeManager: 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 ) @@ -279,42 +285,44 @@ class ESPHomeManager: await self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) + async def _send_home_assistant_state_event( + self, + attribute: str | None, + event: EventType[EventStateChangedData], + ) -> None: + """Forward Home Assistant states updates to ESPHome.""" + event_data = event.data + new_state = event_data["new_state"] + old_state = event_data["old_state"] + + if new_state is None or old_state is None: + return + + # Only communicate changes to the state or attribute tracked + if (not attribute and old_state.state == new_state.state) or ( + attribute + and old_state.attributes.get(attribute) + == new_state.attributes.get(attribute) + ): + return + + await self._send_home_assistant_state( + event.data["entity_id"], attribute, new_state + ) + @callback def async_on_state_subscription( self, entity_id: str, attribute: str | None = None ) -> None: """Subscribe and forward states for requested entities.""" hass = self.hass - - async def send_home_assistant_state_event( - event: EventType[EventStateChangedData], - ) -> None: - """Forward Home Assistant states updates to ESPHome.""" - event_data = event.data - new_state = event_data["new_state"] - old_state = event_data["old_state"] - - if new_state is None or old_state is None: - return - - # Only communicate changes to the state or attribute tracked - if (not attribute and old_state.state == new_state.state) or ( - attribute - and old_state.attributes.get(attribute) - == new_state.attributes.get(attribute) - ): - return - - await self._send_home_assistant_state( - event.data["entity_id"], attribute, new_state - ) - self.entry_data.disconnect_callbacks.add( async_track_state_change_event( - hass, [entity_id], send_home_assistant_state_event + hass, + [entity_id], + partial(self._send_home_assistant_state_event, attribute), ) ) - # Send initial state hass.async_create_task( self._send_home_assistant_state( @@ -344,7 +352,6 @@ class ESPHomeManager: if self.voice_assistant_udp_server is not None: _LOGGER.warning("Voice assistant UDP server was not stopped") self.voice_assistant_udp_server.stop() - self.voice_assistant_udp_server.close() self.voice_assistant_udp_server = None hass = self.hass @@ -375,6 +382,17 @@ class ESPHomeManager: self.voice_assistant_udp_server.stop() async def on_connect(self) -> None: + """Subscribe to states and list entities on successful API login.""" + try: + await self._on_connnect() + except APIConnectionError as err: + _LOGGER.warning( + "Error getting setting up connection for %s: %s", self.host, err + ) + # Re-connection logic will trigger after this + await self.cli.disconnect() + + async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry unique_id = entry.unique_id @@ -385,16 +403,10 @@ class ESPHomeManager: cli = self.cli stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id - try: - 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 + results = await asyncio.gather( + cli.device_info(), + cli.list_entities_services(), + ) device_info: EsphomeDeviceInfo = results[0] entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] @@ -456,12 +468,10 @@ class ESPHomeManager: self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state(hass) - await asyncio.gather( - entry_data.async_update_static_infos( - hass, entry, entity_infos, device_info.mac_address - ), - _setup_services(hass, entry_data, services), + 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] @@ -481,18 +491,12 @@ class ESPHomeManager: ) ) - try: - 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), - ) - 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() - return + 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), + ) for result_idx in range(len(setup_coros_with_disconnect_callbacks)): cancel_callback = setup_results[result_idx] @@ -586,7 +590,7 @@ class ESPHomeManager: 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 @@ -708,12 +712,27 @@ ARG_TYPE_METADATA = { } -async def _register_service( - hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService +async def execute_service( + entry_data: RuntimeEntryData, service: UserService, call: ServiceCall ) -> 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}" + """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: + """Register a service on a node.""" + service_name = build_service_name(device_info, service) schema = {} fields = {} @@ -736,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 @@ -780,11 +802,11 @@ 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: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4a1301ccf29..35b8e91f12b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,9 +15,9 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.1", + "aioesphomeapi==21.0.2", "esphome-dashboard-api==1.2.3", - "bleak-esphome==0.4.0" + "bleak-esphome==0.4.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index bc694ec39cf..f1902bdb39d 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -54,7 +54,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): super()._on_static_info_update(static_info) static_info = self._static_info self._attr_device_class = try_parse_enum( - NumberDeviceClass, self._static_info.device_class + NumberDeviceClass, static_info.device_class ) self._attr_native_min_value = static_info.min_value self._attr_native_max_value = static_info.max_value diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index a3464b137dc..3d4d296bb87 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN from .domain_data import DomainData from .entity import ( EsphomeAssistEntity, @@ -75,7 +76,7 @@ class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) - AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) + AssistPipelineSelect.__init__(self, hass, DOMAIN, self._device_info.mac_address) class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 859b28a53b5..ea052522e76 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, cast +from typing import Any from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo @@ -27,6 +27,7 @@ from .entry_data import RuntimeEntryData 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 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): (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 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): 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 de6b521d980..7c5c74d58ee 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -1,4 +1,5 @@ """ESPHome voice assistant support.""" + from __future__ import annotations import asyncio @@ -67,7 +68,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Receive UDP packets and forward them to the voice assistant.""" started = False - stopped = False + stop_requested = False transport: asyncio.DatagramTransport | None = None remote_addr: tuple[str, int] | None = None @@ -92,6 +93,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self._tts_done = asyncio.Event() self._tts_task: asyncio.Task | None = None + @property + def is_running(self) -> bool: + """True if the the UDP server is started and hasn't been asked to stop.""" + return self.started and (not self.stop_requested) + async def start_server(self) -> int: """Start accepting connections.""" @@ -99,7 +105,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Accept connection.""" if self.started: raise RuntimeError("Can only start once") - if self.stopped: + if self.stop_requested: raise RuntimeError("No longer accepting connections") self.started = True @@ -124,7 +130,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" - if not self.started or self.stopped: + if not self.is_running: return if self.remote_addr is None: self.remote_addr = addr @@ -142,19 +148,19 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): def stop(self) -> None: """Stop the receiver.""" self.queue.put_nowait(b"") - self.started = False - self.stopped = True + self.close() def close(self) -> None: """Close the receiver.""" self.started = False - self.stopped = True + self.stop_requested = True + if self.transport is not None: self.transport.close() async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if not self.started or self.stopped: + if not self.is_running: raise RuntimeError("Not running") while data := await self.queue.get(): @@ -303,8 +309,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): async def _send_tts(self, media_id: str) -> None: """Send TTS audio to device via UDP.""" + # Always send stream start/end events + self.handle_event(VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {}) + try: - if self.transport is None: + if (not self.is_running) or (self.transport is None): return extension, data = await tts.async_get_media_source_audio( @@ -337,15 +346,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): _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 = audio_bytes_size // bytes_per_sample - while samples_left > 0: + while (samples_left > 0) and self.is_running: bytes_offset = sample_offset * bytes_per_sample chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] samples_in_chunk = len(chunk) // bytes_per_sample diff --git a/homeassistant/components/event/icons.json b/homeassistant/components/event/icons.json new file mode 100644 index 00000000000..92f2e7a6546 --- /dev/null +++ b/homeassistant/components/event/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:eye-check" + }, + "button": { + "default": "mdi:gesture-tap-button" + }, + "doorbell": { + "default": "mdi:doorbell" + }, + "motion": { + "default": "mdi:motion-sensor" + } + } +} diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 3d65a5516c7..44d46d27a9d 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -50,7 +50,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): +class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module """Update coordinator for Evil Genius data.""" info: dict diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 390bdeb3f33..ddad635ddcf 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -160,6 +160,7 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: def _handle_exception(err: evo.RequestFailed) -> None: """Return False if the exception can't be ignored.""" + try: raise err @@ -471,12 +472,13 @@ class EvoBroker: async def call_client_api( self, - api_function: Awaitable[dict[str, Any] | None], + client_api: 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 + result = await client_api except evo.RequestFailed as err: _handle_exception(err) return None @@ -556,7 +558,6 @@ class EvoBroker: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status) finally: if access_token != self.client.access_token: @@ -657,9 +658,9 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if self._evo_broker.temps.get(self._evo_id) is not None: + if (temp := self._evo_broker.temps.get(self._evo_id)) is not None: # use high-precision temps if available - return self._evo_broker.temps[self._evo_id] + return temp return self._evo_device.temperature @property @@ -673,7 +674,7 @@ class EvoChild(EvoDevice): dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset return dt_util.as_local(dt_aware) - if not self._schedule or not self._schedule.get("DailySchedules"): + if not (schedule := self._schedule.get("DailySchedules")): return {} # no scheduled setpoints when {'DailySchedules': []} day_time = dt_util.now() @@ -682,7 +683,7 @@ class EvoChild(EvoDevice): try: # Iterate today's switchpoints until past the current time of day... - day = self._schedule["DailySchedules"][day_of_week] + day = schedule[day_of_week] sp_idx = -1 # last switchpoint of the day before for i, tmp in enumerate(day["Switchpoints"]): if time_of_day > tmp["TimeOfDay"]: @@ -699,7 +700,7 @@ class EvoChild(EvoDevice): ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), ): sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] + day = schedule[(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] switchpoint_time_of_day = dt_util.parse_datetime( @@ -730,9 +731,17 @@ class EvoChild(EvoDevice): 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 - ) + try: + self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] + self._evo_device.get_schedule(), update_state=False + ) + except evo.InvalidSchedule as err: + _LOGGER.warning( + "%s: Unable to retrieve the schedule: %s", + self._evo_device, + err, + ) + self._schedule = {} _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 1e092d7fc34..8b74d31cc0d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -156,6 +156,7 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): """Base for an evohome Climate device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self) -> list[HVACMode]: @@ -182,8 +183,6 @@ class EvoZone(EvoChild, EvoClimateEntity): else: self._attr_unique_id = evo_device.zoneId - self._attr_name = evo_device.name - if evo_broker.client_v1: self._attr_precision = PRECISION_TENTHS else: @@ -192,7 +191,10 @@ class EvoZone(EvoChild, EvoClimateEntity): ] self._attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: @@ -219,6 +221,11 @@ class EvoZone(EvoChild, EvoClimateEntity): self._evo_device.set_temperature(temperature, until=until) ) + @property + def name(self) -> str | None: + """Return the name of the evohome entity.""" + return self._evo_device.name # zones can be easily renamed + @property def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" @@ -248,16 +255,20 @@ class EvoZone(EvoChild, EvoClimateEntity): def min_temp(self) -> float: """Return the minimum target temperature of a Zone. - The default is 5, but is user-configurable within 5-35 (in Celsius). + The default is 5, but is user-configurable within 5-21 (in Celsius). """ + if self._evo_device.min_heat_setpoint is None: + return 5 return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: """Return the maximum target temperature of a Zone. - The default is 35, but is user-configurable within 5-35 (in Celsius). + The default is 35, but is user-configurable within 21-35 (in Celsius). """ + if self._evo_device.max_heat_setpoint is None: + return 35 return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: @@ -365,6 +376,9 @@ class EvoController(EvoClimateEntity): ] if self._attr_preset_modes: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (system mode) for a controller. diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 062bba1cfdc..9d32ba98e92 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.15"] + "requirements": ["evohome-async==0.4.17"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 77a7b1c2ced..26a60f9ec08 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -89,6 +89,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): self._evo_id = evo_device.dhwId self._attr_unique_id = evo_device.dhwId + self._attr_name = evo_device.name # is static self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index e42968603e4..6397d8a27dc 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError -import voluptuous as vol from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature @@ -17,34 +16,19 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - issue_registry as ir, -) +from homeassistant.helpers import discovery_flow from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) from .const import ( - ATTR_DIRECTION, - ATTR_ENABLE, - ATTR_LEVEL, ATTR_SERIAL, - ATTR_SPEED, CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DIR_DOWN, - DIR_LEFT, - DIR_RIGHT, - DIR_UP, DOMAIN, - SERVICE_ALARM_SOUND, - SERVICE_ALARM_TRIGGER, - SERVICE_PTZ, SERVICE_WAKE_DEVICE, ) from .coordinator import EzvizDataUpdateCoordinator @@ -126,35 +110,10 @@ async def async_setup_entry( platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_PTZ, - { - vol.Required(ATTR_DIRECTION): vol.In( - [DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT] - ), - vol.Required(ATTR_SPEED): cv.positive_int, - }, - "perform_ptz", - ) - - platform.async_register_entity_service( - SERVICE_ALARM_TRIGGER, - { - vol.Required(ATTR_ENABLE): cv.positive_int, - }, - "perform_sound_alarm", - ) - platform.async_register_entity_service( SERVICE_WAKE_DEVICE, {}, "perform_wake_device" ) - platform.async_register_entity_service( - SERVICE_ALARM_SOUND, - {vol.Required(ATTR_LEVEL): cv.positive_int}, - "perform_alarm_sound", - ) - class EzvizCamera(EzvizEntity, Camera): """An implementation of a EZVIZ security camera.""" @@ -251,70 +210,9 @@ class EzvizCamera(EzvizEntity, Camera): return self._rtsp_stream - def perform_ptz(self, direction: str, speed: int) -> None: - """Perform a PTZ action on the camera.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_ptz", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_ptz", - ) - - try: - self.coordinator.ezviz_client.ptz_control( - str(direction).upper(), self._serial, "START", speed - ) - self.coordinator.ezviz_client.ptz_control( - str(direction).upper(), self._serial, "STOP", speed - ) - - except HTTPError as err: - raise HTTPError("Cannot perform PTZ") from err - - def perform_sound_alarm(self, enable: int) -> None: - """Sound the alarm on a camera.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_sound_alarm", - breaks_in_ha_version="2024.3.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_sound_alarm", - ) - - try: - self.coordinator.ezviz_client.sound_alarm(self._serial, enable) - except HTTPError as err: - raise HTTPError("Cannot sound alarm") from err - def perform_wake_device(self) -> None: """Basically wakes the camera by querying the device.""" try: self.coordinator.ezviz_client.get_detection_sensibility(self._serial) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot wake device") from err - - def perform_alarm_sound(self, level: int) -> None: - """Enable/Disable movement sound alarm.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_alarm_sound_level", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_alarm_sound_level", - ) - try: - self.coordinator.ezviz_client.alarm_sound(self._serial, level, 1) - except HTTPError as err: - raise HTTPError( - "Cannot set alarm sound level for on movement detected" - ) from err diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index c28d84552d6..651110dd5d7 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -24,10 +24,7 @@ ATTR_LEVEL = "level" ATTR_TYPE = "type_value" # Service names -SERVICE_PTZ = "ptz" -SERVICE_ALARM_TRIGGER = "sound_alarm" SERVICE_WAKE_DEVICE = "wake_device" -SERVICE_ALARM_SOUND = "alarm_sound" SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" # Defaults diff --git a/homeassistant/components/ezviz/services.yaml b/homeassistant/components/ezviz/services.yaml index 7d1cda2fa63..756a0fc67ef 100644 --- a/homeassistant/components/ezviz/services.yaml +++ b/homeassistant/components/ezviz/services.yaml @@ -1,46 +1,3 @@ -alarm_sound: - target: - entity: - integration: ezviz - domain: camera - fields: - level: - required: true - example: 0 - default: 0 - selector: - number: - min: 0 - max: 2 - step: 1 - mode: box -ptz: - target: - entity: - integration: ezviz - domain: camera - fields: - direction: - required: true - example: "up" - default: "up" - selector: - select: - options: - - "up" - - "down" - - "left" - - "right" - speed: - required: true - example: 5 - default: 5 - selector: - number: - min: 1 - max: 9 - step: 1 - mode: box set_alarm_detection_sensibility: target: entity: @@ -66,22 +23,7 @@ set_alarm_detection_sensibility: options: - "0" - "3" -sound_alarm: - target: - entity: - integration: ezviz - domain: camera - fields: - enable: - required: true - example: 1 - default: 1 - selector: - number: - min: 1 - max: 2 - step: 1 - mode: box + wake_device: target: entity: diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 11ec31fee4a..58ac9dfde09 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -59,41 +59,6 @@ } } }, - "issues": { - "service_deprecation_alarm_sound_level": { - "title": "Ezviz Alarm sound level service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_deprecation_alarm_sound_level::title%]", - "description": "Ezviz Alarm sound level service is deprecated and will be removed.\nTo set the Alarm sound level, you can instead use the `select.select_option` service targetting the Warning sound entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, - "service_depreciation_ptz": { - "title": "EZVIZ PTZ service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_ptz::title%]", - "description": "EZVIZ PTZ service is deprecated and will be removed.\nTo move the camera, you can instead use the `button.press` service targetting the PTZ* entities.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, - "service_depreciation_sound_alarm": { - "title": "Ezviz Sound alarm service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_sound_alarm::title%]", - "description": "Ezviz Sound alarm service is deprecated and will be removed.\nTo sound the alarm, you can instead use the `siren.toggle` service targeting the Siren entity.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to fix this issue." - } - } - } - } - }, "entity": { "select": { "alarm_sound_mode": { @@ -219,30 +184,6 @@ } }, "services": { - "alarm_sound": { - "name": "Set warning sound level.", - "description": "Setx movement warning sound level.", - "fields": { - "level": { - "name": "Sound level", - "description": "Sound level (2 is disabled, 1 intensive, 0 soft)." - } - } - }, - "ptz": { - "name": "PTZ", - "description": "Moves the camera to the direction, with defined speed.", - "fields": { - "direction": { - "name": "Direction", - "description": "Direction to move camera (up, down, left, right)." - }, - "speed": { - "name": "Speed", - "description": "Speed of movement (from 1 to 9)." - } - } - }, "set_alarm_detection_sensibility": { "name": "Detection sensitivity", "description": "Sets the detection sensibility level.", @@ -257,16 +198,6 @@ } } }, - "sound_alarm": { - "name": "Sound alarm", - "description": "Sounds the alarm on your camera.", - "fields": { - "enable": { - "name": "Alarm sound", - "description": "Enter 1 or 2 (1=disable, 2=enable)." - } - } - }, "wake_device": { "name": "Wake camera", "description": "This can be used to wake the camera/device from hibernation." diff --git a/homeassistant/components/facebox/__init__.py b/homeassistant/components/facebox/__init__.py deleted file mode 100644 index 9e5b6afb10b..00000000000 --- a/homeassistant/components/facebox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The facebox component.""" diff --git a/homeassistant/components/facebox/const.py b/homeassistant/components/facebox/const.py deleted file mode 100644 index 991ec925a98..00000000000 --- a/homeassistant/components/facebox/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Constants for the Facebox component.""" - -DOMAIN = "facebox" -SERVICE_TEACH_FACE = "teach_face" diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py deleted file mode 100644 index 5584efb883a..00000000000 --- a/homeassistant/components/facebox/image_processing.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Component for facial detection and identification via facebox.""" -from __future__ import annotations - -import base64 -from http import HTTPStatus -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.image_processing import ( - ATTR_CONFIDENCE, - PLATFORM_SCHEMA, - ImageProcessingFaceEntity, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ID, - ATTR_NAME, - CONF_ENTITY_ID, - CONF_IP_ADDRESS, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SOURCE, - CONF_USERNAME, -) -from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import DOMAIN, SERVICE_TEACH_FACE - -_LOGGER = logging.getLogger(__name__) - -ATTR_BOUNDING_BOX = "bounding_box" -ATTR_CLASSIFIER = "classifier" -ATTR_IMAGE_ID = "image_id" -ATTR_MATCHED = "matched" -FACEBOX_NAME = "name" -CLASSIFIER = "facebox" -DATA_FACEBOX = "facebox_classifiers" -FILE_PATH = "file_path" - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - } -) - -SERVICE_TEACH_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_NAME): cv.string, - vol.Required(FILE_PATH): cv.string, - } -) - - -def check_box_health(url, username, password): - """Check the health of the classifier and return its id if healthy.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - response = requests.get(url, **kwargs, timeout=10) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - return None - if response.status_code == HTTPStatus.OK: - return response.json()["hostname"] - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - return None - - -def encode_image(image): - """base64 encode an image stream.""" - base64_img = base64.b64encode(image).decode("ascii") - return base64_img - - -def get_matched_faces(faces): - """Return the name and rounded confidence of matched faces.""" - return { - face["name"]: round(face["confidence"], 2) for face in faces if face["matched"] - } - - -def parse_faces(api_faces): - """Parse the API face data into the format required.""" - known_faces = [] - for entry in api_faces: - face = {} - if entry["matched"]: # This data is only in matched faces. - face[FACEBOX_NAME] = entry["name"] - face[ATTR_IMAGE_ID] = entry["id"] - else: # Lets be explicit. - face[FACEBOX_NAME] = None - face[ATTR_IMAGE_ID] = None - face[ATTR_CONFIDENCE] = round(100.0 * entry["confidence"], 2) - face[ATTR_MATCHED] = entry["matched"] - face[ATTR_BOUNDING_BOX] = entry["rect"] - known_faces.append(face) - return known_faces - - -def post_image(url, image, username, password): - """Post an image to the classifier.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - response = requests.post( - url, json={"base64": encode_image(image)}, timeout=10, **kwargs - ) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - return None - return response - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - return None - - -def teach_file(url, name, file_path, username, password): - """Teach the classifier a name associated with a file.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - with open(file_path, "rb") as open_file: - response = requests.post( - url, - data={FACEBOX_NAME: name, ATTR_ID: file_path}, - files={"file": open_file}, - timeout=10, - **kwargs, - ) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - elif response.status_code == HTTPStatus.BAD_REQUEST: - _LOGGER.error( - "%s teaching of file %s failed with message:%s", - CLASSIFIER, - file_path, - response.text, - ) - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - - -def valid_file_path(file_path): - """Check that a file_path points to a valid file.""" - try: - cv.isfile(file_path) - return True - except vol.Invalid: - _LOGGER.error("%s error: Invalid file path: %s", CLASSIFIER, file_path) - return False - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the classifier.""" - if DATA_FACEBOX not in hass.data: - hass.data[DATA_FACEBOX] = [] - - ip_address = config[CONF_IP_ADDRESS] - port = config[CONF_PORT] - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - url_health = f"http://{ip_address}:{port}/healthz" - hostname = check_box_health(url_health, username, password) - if hostname is None: - return - - entities = [] - for camera in config[CONF_SOURCE]: - facebox = FaceClassifyEntity( - ip_address, - port, - username, - password, - hostname, - camera[CONF_ENTITY_ID], - camera.get(CONF_NAME), - ) - entities.append(facebox) - hass.data[DATA_FACEBOX].append(facebox) - add_entities(entities) - - def service_handle(service: ServiceCall) -> None: - """Handle for services.""" - entity_ids = service.data.get("entity_id") - - classifiers = hass.data[DATA_FACEBOX] - if entity_ids: - classifiers = [c for c in classifiers if c.entity_id in entity_ids] - - for classifier in classifiers: - name = service.data.get(ATTR_NAME) - file_path = service.data.get(FILE_PATH) - classifier.teach(name, file_path) - - hass.services.register( - DOMAIN, SERVICE_TEACH_FACE, service_handle, schema=SERVICE_TEACH_SCHEMA - ) - - -class FaceClassifyEntity(ImageProcessingFaceEntity): - """Perform a face classification.""" - - def __init__( - self, ip_address, port, username, password, hostname, camera_entity, name=None - ): - """Init with the API key and model id.""" - super().__init__() - self._url_check = f"http://{ip_address}:{port}/{CLASSIFIER}/check" - self._url_teach = f"http://{ip_address}:{port}/{CLASSIFIER}/teach" - self._username = username - self._password = password - self._hostname = hostname - self._camera = camera_entity - if name: - self._name = name - else: - camera_name = split_entity_id(camera_entity)[1] - self._name = f"{CLASSIFIER} {camera_name}" - self._matched = {} - - def process_image(self, image): - """Process an image.""" - response = post_image(self._url_check, image, self._username, self._password) - if response: - response_json = response.json() - if response_json["success"]: - total_faces = response_json["facesCount"] - faces = parse_faces(response_json["faces"]) - self._matched = get_matched_faces(faces) - self.process_faces(faces, total_faces) - - else: - self.total_faces = None - self.faces = [] - self._matched = {} - - def teach(self, name, file_path): - """Teach classifier a face name.""" - if not self.hass.config.is_allowed_path(file_path) or not valid_file_path( - file_path - ): - return - teach_file(self._url_teach, name, file_path, self._username, self._password) - - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the classifier attributes.""" - return { - "matched_faces": self._matched, - "total_matched_faces": len(self._matched), - "hostname": self._hostname, - } diff --git a/homeassistant/components/facebox/manifest.json b/homeassistant/components/facebox/manifest.json deleted file mode 100644 index f552fef1b87..00000000000 --- a/homeassistant/components/facebox/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "facebox", - "name": "Facebox", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/facebox", - "iot_class": "local_push" -} diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml deleted file mode 100644 index 0438338f55e..00000000000 --- a/homeassistant/components/facebox/services.yaml +++ /dev/null @@ -1,17 +0,0 @@ -teach_face: - fields: - entity_id: - selector: - entity: - integration: facebox - domain: image_processing - name: - required: true - example: "my_name" - selector: - text: - file_path: - required: true - example: "/images/my_image.jpg" - selector: - text: diff --git a/homeassistant/components/facebox/strings.json b/homeassistant/components/facebox/strings.json deleted file mode 100644 index 1869673b643..00000000000 --- a/homeassistant/components/facebox/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "services": { - "teach_face": { - "name": "Teach face", - "description": "Teaches facebox a face using a file.", - "fields": { - "entity_id": { - "name": "Entity", - "description": "The facebox entity to teach." - }, - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "The name of the face to teach." - }, - "file_path": { - "name": "File path", - "description": "The path to the image file." - } - } - } - } -} diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json new file mode 100644 index 00000000000..ebc4988e87f --- /dev/null +++ b/homeassistant/components/fan/icons.json @@ -0,0 +1,28 @@ +{ + "entity_component": { + "_": { + "default": "mdi:fan", + "state": { + "off": "mdi:fan-off" + }, + "state_attributes": { + "direction": { + "default": "mdi:rotate-right", + "state": { + "reverse": "mdi:rotate-left" + } + } + } + } + }, + "services": { + "decrease_speed": "mdi:fan-minus", + "increase_speed": "mdi:fan-plus", + "oscillate": "mdi:arrow-oscillating", + "set_percentage": "mdi:fan", + "set_preset_mode": "mdi:fan-auto", + "toggle": "mdi:fan", + "turn_off": "mdi:fan-off", + "turn_on": "mdi:fan" + } +} diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 165d81edd0b..ada717a6dac 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -6,14 +6,15 @@ import logging import voluptuous as vol 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 +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS from .coordinator import FastdotcomDataUpdateCoordindator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Fast.com component. (deprecated).""" + """Set up the Fastdotcom component.""" if DOMAIN in config: hass.async_create_task( hass.config_entries.flow.async_init( @@ -42,51 +43,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: data=config[DOMAIN], ) ) + async_setup_services(hass) return True 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() - - 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.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, ) + async def _async_finish_startup(hass: HomeAssistant) -> None: + """Run this only when HA has finished its startup.""" + await coordinator.async_config_entry_first_refresh() + + # Don't start a speedtest during startup, this will slow down the overall startup dramatically + async_at_started(hass, _async_finish_startup) return True 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/const.py b/homeassistant/components/fastdotcom/const.py index 753825c4361..340be6f50ae 100644 --- a/homeassistant/components/fastdotcom/const.py +++ b/homeassistant/components/fastdotcom/const.py @@ -10,6 +10,8 @@ DATA_UPDATED = f"{DOMAIN}_data_updated" CONF_MANUAL = "manual" +SERVICE_NAME = "speedtest" + DEFAULT_NAME = "Fast.com" DEFAULT_INTERVAL = 1 PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/fastdotcom/services.py b/homeassistant/components/fastdotcom/services.py new file mode 100644 index 00000000000..d1a9ee2125b --- /dev/null +++ b/homeassistant/components/fastdotcom/services.py @@ -0,0 +1,51 @@ +"""Services for the Fastdotcom integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, SERVICE_NAME +from .coordinator import FastdotcomDataUpdateCoordindator + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the service for the Fastdotcom integration.""" + + @callback + def collect_coordinator() -> FastdotcomDataUpdateCoordindator: + """Collect the coordinator Fastdotcom.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + if not config_entries: + raise HomeAssistantError("No Fast.com config entries found") + + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{config_entry.title} is not loaded") + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][ + config_entry.entry_id + ] + break + return coordinator + + async def async_perform_service(call: ServiceCall) -> None: + """Perform a service call to manually run Fastdotcom.""" + 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", + ) + coordinator = collect_coordinator() + await coordinator.async_request_refresh() + + hass.services.async_register( + DOMAIN, + SERVICE_NAME, + async_perform_service, + ) diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index a98766c78c6..4ab4ee32a09 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio import re +from typing import Generic, TypeVar +from haffmpeg.core import HAFFmpeg from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame import voluptuous as vol @@ -13,9 +15,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -23,15 +26,17 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) + DOMAIN = "ffmpeg" SERVICE_START = "start" SERVICE_STOP = "stop" SERVICE_RESTART = "restart" -SIGNAL_FFMPEG_START = "ffmpeg.start" -SIGNAL_FFMPEG_STOP = "ffmpeg.stop" -SIGNAL_FFMPEG_RESTART = "ffmpeg.restart" +SIGNAL_FFMPEG_START = SignalType[list[str] | None]("ffmpeg.start") +SIGNAL_FFMPEG_STOP = SignalType[list[str] | None]("ffmpeg.stop") +SIGNAL_FFMPEG_RESTART = SignalType[list[str] | None]("ffmpeg.restart") DATA_FFMPEG = "ffmpeg" @@ -66,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Register service async def async_service_handle(service: ServiceCall) -> None: """Handle service ffmpeg process.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) + entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID) if service.service == SERVICE_START: async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids) @@ -128,20 +133,20 @@ async def async_get_image( class FFmpegManager: """Helper for ha-ffmpeg.""" - def __init__(self, hass, ffmpeg_bin): + def __init__(self, hass: HomeAssistant, ffmpeg_bin: str) -> None: """Initialize helper.""" self.hass = hass - self._cache = {} + self._cache = {} # type: ignore[var-annotated] self._bin = ffmpeg_bin - self._version = None - self._major_version = None + self._version: str | None = None + self._major_version: int | None = None @property - def binary(self): + def binary(self) -> str: """Return ffmpeg binary from config.""" return self._bin - async def async_get_version(self): + async def async_get_version(self) -> tuple[str | None, int | None]: """Return ffmpeg version.""" ffversion = FFVersion(self._bin) @@ -156,7 +161,7 @@ class FFmpegManager: return self._version, self._major_version @property - def ffmpeg_stream_content_type(self): + def ffmpeg_stream_content_type(self) -> str: """Return HTTP content type for ffmpeg stream.""" if self._major_version is not None and self._major_version > 3: return CONTENT_TYPE_MULTIPART.format("ffmpeg") @@ -164,17 +169,17 @@ class FFmpegManager: return CONTENT_TYPE_MULTIPART.format("ffserver") -class FFmpegBase(Entity): +class FFmpegBase(Entity, Generic[_HAFFmpegT]): """Interface object for FFmpeg.""" _attr_should_poll = False - def __init__(self, initial_state=True): + def __init__(self, ffmpeg: _HAFFmpegT, initial_state: bool = True) -> None: """Initialize ffmpeg base object.""" - self.ffmpeg = None + self.ffmpeg = ffmpeg self.initial_state = initial_state - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register dispatcher & events. This method is a coroutine. @@ -199,18 +204,18 @@ class FFmpegBase(Entity): self._async_register_events() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self.ffmpeg.is_running - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg process. This method is a coroutine. """ raise NotImplementedError() - async def _async_stop_ffmpeg(self, entity_ids): + async def _async_stop_ffmpeg(self, entity_ids: list[str] | None) -> None: """Stop a FFmpeg process. This method is a coroutine. @@ -218,7 +223,7 @@ class FFmpegBase(Entity): if entity_ids is None or self.entity_id in entity_ids: await self.ffmpeg.close() - async def _async_restart_ffmpeg(self, entity_ids): + async def _async_restart_ffmpeg(self, entity_ids: list[str] | None) -> None: """Stop a FFmpeg process. This method is a coroutine. @@ -228,10 +233,10 @@ class FFmpegBase(Entity): await self._async_start_ffmpeg(None) @callback - def _async_register_events(self): + def _async_register_events(self) -> None: """Register a FFmpeg process/device.""" - async def async_shutdown_handle(event): + async def async_shutdown_handle(event: Event) -> None: """Stop FFmpeg process.""" await self._async_stop_ffmpeg(None) @@ -241,7 +246,7 @@ class FFmpegBase(Entity): if not self.initial_state: return - async def async_start_handle(event): + async def async_start_handle(event: Event) -> None: """Start FFmpeg process.""" await self._async_start_ffmpeg(None) self.async_write_ha_state() diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index fb2519fb071..884629c8ae6 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,6 +1,8 @@ """Support for Cameras with FFmpeg as decoder.""" from __future__ import annotations +from typing import Any + from aiohttp import web from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -14,7 +16,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_EXTRA_ARGUMENTS, CONF_INPUT, DATA_FFMPEG, async_get_image +from . import ( + CONF_EXTRA_ARGUMENTS, + CONF_INPUT, + DATA_FFMPEG, + FFmpegManager, + async_get_image, +) DEFAULT_NAME = "FFmpeg" DEFAULT_ARGUMENTS = "-pred 1" @@ -43,16 +51,16 @@ class FFmpegCamera(Camera): _attr_supported_features = CameraEntityFeature.STREAM - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize a FFmpeg camera.""" super().__init__() - self._manager = hass.data[DATA_FFMPEG] - self._name = config.get(CONF_NAME) - self._input = config.get(CONF_INPUT) - self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) + self._manager: FFmpegManager = hass.data[DATA_FFMPEG] + self._name: str = config[CONF_NAME] + self._input: str = config[CONF_INPUT] + self._extra_arguments: str = config[CONF_EXTRA_ARGUMENTS] - async def stream_source(self): + async def stream_source(self) -> str: """Return the stream source.""" return self._input.split(" ")[-1] @@ -87,6 +95,6 @@ class FFmpegCamera(Camera): await stream.close() @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._name diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index c3603f74a5a..b982d944c6a 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -1,6 +1,9 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" from __future__ import annotations +from typing import Any, TypeVar + +from haffmpeg.core import HAFFmpeg import haffmpeg.sensor as ffmpeg_sensor import voluptuous as vol @@ -14,6 +17,7 @@ from homeassistant.components.ffmpeg import ( CONF_INITIAL_STATE, CONF_INPUT, FFmpegBase, + FFmpegManager, get_ffmpeg_manager, ) from homeassistant.const import CONF_NAME, CONF_REPEAT @@ -22,6 +26,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) + CONF_RESET = "reset" CONF_CHANGES = "changes" CONF_REPEAT_TIME = "repeat_time" @@ -63,43 +69,45 @@ async def async_setup_platform( async_add_entities([entity]) -class FFmpegBinarySensor(FFmpegBase, BinarySensorEntity): +class FFmpegBinarySensor(FFmpegBase[_HAFFmpegT], BinarySensorEntity): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, config): + def __init__(self, ffmpeg: _HAFFmpegT, config: dict[str, Any]) -> None: """Init for the binary sensor noise detection.""" - super().__init__(config.get(CONF_INITIAL_STATE)) + super().__init__(ffmpeg, config[CONF_INITIAL_STATE]) - self._state = False + self._state: bool | None = False self._config = config - self._name = config.get(CONF_NAME) + self._name: str = config[CONF_NAME] @callback - def _async_callback(self, state): + def _async_callback(self, state: bool | None) -> None: """HA-FFmpeg callback for noise detection.""" self._state = state self.async_write_ha_state() @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._state @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._name -class FFmpegMotion(FFmpegBinarySensor): +class FFmpegMotion(FFmpegBinarySensor[ffmpeg_sensor.SensorMotion]): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, hass, manager, config): + def __init__( + self, hass: HomeAssistant, manager: FFmpegManager, config: dict[str, Any] + ) -> None: """Initialize FFmpeg motion binary sensor.""" - super().__init__(config) - self.ffmpeg = ffmpeg_sensor.SensorMotion(manager.binary, self._async_callback) + ffmpeg = ffmpeg_sensor.SensorMotion(manager.binary, self._async_callback) + super().__init__(ffmpeg, config) - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg instance. This method is a coroutine. @@ -109,19 +117,19 @@ class FFmpegMotion(FFmpegBinarySensor): # init config self.ffmpeg.set_options( - time_reset=self._config.get(CONF_RESET), + time_reset=self._config[CONF_RESET], time_repeat=self._config.get(CONF_REPEAT_TIME, 0), repeat=self._config.get(CONF_REPEAT, 0), - changes=self._config.get(CONF_CHANGES), + changes=self._config[CONF_CHANGES], ) # run await self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), + input_source=self._config[CONF_INPUT], extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor, from DEVICE_CLASSES.""" return BinarySensorDeviceClass.MOTION diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index a7493930a48..a802868334d 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -1,6 +1,8 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" from __future__ import annotations +from typing import Any + import haffmpeg.sensor as ffmpeg_sensor import voluptuous as vol @@ -13,6 +15,7 @@ from homeassistant.components.ffmpeg import ( CONF_INITIAL_STATE, CONF_INPUT, CONF_OUTPUT, + FFmpegManager, get_ffmpeg_manager, ) from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor @@ -59,16 +62,18 @@ async def async_setup_platform( async_add_entities([entity]) -class FFmpegNoise(FFmpegBinarySensor): +class FFmpegNoise(FFmpegBinarySensor[ffmpeg_sensor.SensorNoise]): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, hass, manager, config): + def __init__( + self, hass: HomeAssistant, manager: FFmpegManager, config: dict[str, Any] + ) -> None: """Initialize FFmpeg noise binary sensor.""" - super().__init__(config) - self.ffmpeg = ffmpeg_sensor.SensorNoise(manager.binary, self._async_callback) + ffmpeg = ffmpeg_sensor.SensorNoise(manager.binary, self._async_callback) + super().__init__(ffmpeg, config) - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg instance. This method is a coroutine. @@ -77,18 +82,18 @@ class FFmpegNoise(FFmpegBinarySensor): return self.ffmpeg.set_options( - time_duration=self._config.get(CONF_DURATION), - time_reset=self._config.get(CONF_RESET), - peak=self._config.get(CONF_PEAK), + time_duration=self._config[CONF_DURATION], + time_reset=self._config[CONF_RESET], + peak=self._config[CONF_PEAK], ) await self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), + input_source=self._config[CONF_INPUT], output_dest=self._config.get(CONF_OUTPUT), extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor, from DEVICE_CLASSES.""" return BinarySensorDeviceClass.SOUND diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8b41c4f404f..159ba62bd24 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -42,12 +42,12 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.LIGHT, + Platform.LOCK, Platform.SCENE, Platform.SENSOR, - Platform.LOCK, Platform.SWITCH, - Platform.EVENT, ] FIBARO_TYPEMAP = { diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 18fef8dbe7a..42b8a5c0446 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -126,6 +126,8 @@ async def async_setup_entry( class FibaroThermostat(FibaroDevice, ClimateEntity): """Representation of a Fibaro Thermostat.""" + _enable_turn_on_off_backwards_compatibility = False + def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) @@ -209,6 +211,11 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): if mode in OPMODES_PRESET: self._attr_preset_modes.append(OPMODES_PRESET[mode]) + if HVACMode.OFF in self._attr_hvac_modes and len(self._attr_hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" _LOGGER.debug( diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 981b81fdd43..17de9a6636a 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 @@ class FibaroLight(FibaroDevice, LightEntity): 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 @@ class FibaroLight(FibaroDevice, LightEntity): 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 @@ class FibaroLight(FibaroDevice, LightEntity): 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/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index a9a4323fe12..cb7a18dfcac 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -25,7 +25,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 49e51a0fd98..0f49c0858f5 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -7,6 +7,7 @@ from typing import Any, TypeVar, cast from fitbit import Fitbit from fitbit.exceptions import HTTPException, HTTPUnauthorized +from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -132,6 +133,9 @@ class FitbitApi(ABC): """Run client command.""" try: return await self._hass.async_add_executor_job(func) + except RequestsConnectionError as err: + _LOGGER.debug("Connection error to fitbit API: %s", err) + raise FitbitApiException("Connection error to fitbit API") from err except HTTPUnauthorized as err: _LOGGER.debug("Unauthorized error from fitbit API: %s", err) raise FitbitAuthException("Authentication error from fitbit API") from err diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index b833617f2ca..85d5e9f4eac 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -69,6 +69,7 @@ class Flexit(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, hub: ModbusHub, modbus_slave: int | None, name: str | None diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index c9a0b332d93..ba7134d7e50 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -1,42 +1,37 @@ """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.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import FlexitCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, +] 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]) + device_id = 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 + coordinator = FlexitCoordinator(hass, device_id) + 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.""" + """Unload the Flexit Nordic (BACnet) config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py new file mode 100644 index 00000000000..b014fbca415 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -0,0 +1,72 @@ +"""The Flexit Nordic (BACnet) integration.""" +from collections.abc import Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet + +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 . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Flexit binary sensor entity.""" + + value_fn: Callable[[FlexitBACnet], bool] + + +SENSOR_TYPES: tuple[FlexitBinarySensorEntityDescription, ...] = ( + FlexitBinarySensorEntityDescription( + key="air_filter_polluted", + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="air_filter_polluted", + value_fn=lambda data: data.air_filter_polluted, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) binary sensor from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitBinarySensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class FlexitBinarySensor(FlexitEntity, BinarySensorEntity): + """Representation of a Flexit binary Sensor.""" + + entity_description: FlexitBinarySensorEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitBinarySensorEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return value of binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 79846bee019..0d8a381a014 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -6,7 +6,6 @@ from flexit_bacnet import ( VENTILATION_MODE_AWAY, VENTILATION_MODE_HOME, VENTILATION_MODE_STOP, - FlexitBACnet, ) from flexit_bacnet.bacnet import DecodingError @@ -22,7 +21,6 @@ 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 ( @@ -32,6 +30,8 @@ from .const import ( PRESET_TO_VENTILATION_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP, ) +from .coordinator import FlexitCoordinator +from .entity import FlexitEntity async def async_setup_entry( @@ -40,18 +40,16 @@ async def async_setup_entry( async_add_devices: AddEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" - device = hass.data[DOMAIN][config_entry.entry_id] + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_devices([FlexitClimateEntity(device)]) + async_add_devices([FlexitClimateEntity(coordinator)]) -class FlexitClimateEntity(ClimateEntity): +class FlexitClimateEntity(FlexitEntity, ClimateEntity): """Flexit air handling unit.""" _attr_name = None - _attr_has_entity_name = True - _attr_hvac_modes = [ HVACMode.OFF, HVACMode.FAN_ONLY, @@ -64,44 +62,39 @@ class FlexitClimateEntity(ClimateEntity): ] _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _enable_turn_on_off_backwards_compatibility = False - def __init__(self, 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, - ) + def __init__(self, coordinator: FlexitCoordinator) -> None: + """Initialize the Flexit unit.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.device.serial_number async def async_update(self) -> None: """Refresh unit state.""" - await self._device.update() + await self.device.update() @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._device.room_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 + if self.device.ventilation_mode == VENTILATION_MODE_AWAY: + return self.device.air_temp_setpoint_away - return self._device.air_temp_setpoint_home + return self.device.air_temp_setpoint_home async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -109,12 +102,14 @@ class FlexitClimateEntity(ClimateEntity): return try: - if self._device.ventilation_mode == VENTILATION_MODE_AWAY: - await self._device.set_air_temp_setpoint_away(temperature) + 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) + await self.device.set_air_temp_setpoint_home(temperature) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() @property def preset_mode(self) -> str: @@ -122,21 +117,23 @@ class FlexitClimateEntity(ClimateEntity): Requires ClimateEntityFeature.PRESET_MODE. """ - return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_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) + await self.device.set_ventilation_mode(ventilation_mode) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._device.ventilation_mode == VENTILATION_MODE_STOP: + if self.device.ventilation_mode == VENTILATION_MODE_STOP: return HVACMode.OFF return HVACMode.FAN_ONLY @@ -145,8 +142,10 @@ class FlexitClimateEntity(ClimateEntity): """Set new target hvac mode.""" try: if hvac_mode == HVACMode.OFF: - await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + await self.device.set_ventilation_mode(VENTILATION_MODE_STOP) else: - await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + await self.device.set_ventilation_mode(VENTILATION_MODE_HOME) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py new file mode 100644 index 00000000000..556264e1268 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -0,0 +1,49 @@ +"""DataUpdateCoordinator for Flexit Nordic (BACnet) integration..""" +import asyncio.exceptions +from datetime import timedelta +import logging + +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 +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): + """Class to manage fetching data from a Flexit Nordic (BACnet) device.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device_id: str) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_id}", + update_interval=timedelta(seconds=60), + ) + + self.device = FlexitBACnet( + self.config_entry.data[CONF_IP_ADDRESS], + self.config_entry.data[CONF_DEVICE_ID], + ) + + async def _async_update_data(self) -> FlexitBACnet: + """Fetch data from the device.""" + + try: + await self.device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}" + ) from exc + + return self.device diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py new file mode 100644 index 00000000000..3e00fae54af --- /dev/null +++ b/homeassistant/components/flexit_bacnet/entity.py @@ -0,0 +1,34 @@ +"""Base entity for the Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +from flexit_bacnet import FlexitBACnet + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FlexitCoordinator + + +class FlexitEntity(CoordinatorEntity[FlexitCoordinator]): + """Defines a Flexit entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FlexitCoordinator) -> None: + """Initialize a Flexit Nordic (BACnet) entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, coordinator.device.serial_number), + }, + name=coordinator.device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=coordinator.device.serial_number, + ) + + @property + def device(self) -> FlexitBACnet: + """Return the device.""" + return self.coordinator.data diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py new file mode 100644 index 00000000000..590136ad5f7 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -0,0 +1,186 @@ +"""The Flexit Nordic (BACnet) integration.""" +from collections.abc import Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSensorEntityDescription(SensorEntityDescription): + """Describes a Flexit sensor entity.""" + + value_fn: Callable[[FlexitBACnet], float] + + +SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = ( + FlexitSensorEntityDescription( + key="outside_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="outside_air_temperature", + value_fn=lambda data: data.outside_air_temperature, + ), + FlexitSensorEntityDescription( + key="supply_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="supply_air_temperature", + value_fn=lambda data: data.supply_air_temperature, + ), + FlexitSensorEntityDescription( + key="exhaust_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="exhaust_air_temperature", + value_fn=lambda data: data.exhaust_air_temperature, + ), + FlexitSensorEntityDescription( + key="extract_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="extract_air_temperature", + value_fn=lambda data: data.extract_air_temperature, + ), + FlexitSensorEntityDescription( + key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="room_temperature", + value_fn=lambda data: data.room_temperature, + ), + FlexitSensorEntityDescription( + key="fireplace_ventilation_remaining_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key="fireplace_ventilation_remaining_duration", + value_fn=lambda data: data.fireplace_ventilation_remaining_duration, + suggested_display_precision=0, + ), + FlexitSensorEntityDescription( + key="rapid_ventilation_remaining_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key="rapid_ventilation_remaining_duration", + value_fn=lambda data: data.rapid_ventilation_remaining_duration, + suggested_display_precision=0, + ), + FlexitSensorEntityDescription( + key="supply_air_fan_control_signal", + state_class=SensorStateClass.MEASUREMENT, + translation_key="supply_air_fan_control_signal", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.supply_air_fan_control_signal, + ), + FlexitSensorEntityDescription( + key="supply_air_fan_rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + translation_key="supply_air_fan_rpm", + value_fn=lambda data: data.supply_air_fan_rpm, + ), + FlexitSensorEntityDescription( + key="exhaust_air_fan_control_signal", + state_class=SensorStateClass.MEASUREMENT, + translation_key="exhaust_air_fan_control_signal", + value_fn=lambda data: data.exhaust_air_fan_control_signal, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitSensorEntityDescription( + key="exhaust_air_fan_rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + translation_key="exhaust_air_fan_rpm", + value_fn=lambda data: data.exhaust_air_fan_rpm, + ), + FlexitSensorEntityDescription( + key="electric_heater_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + translation_key="electric_heater_power", + value_fn=lambda data: data.electric_heater_power, + suggested_display_precision=3, + ), + FlexitSensorEntityDescription( + key="air_filter_operating_time", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + native_unit_of_measurement=UnitOfTime.HOURS, + translation_key="air_filter_operating_time", + value_fn=lambda data: data.air_filter_operating_time, + ), + FlexitSensorEntityDescription( + key="heat_exchanger_efficiency", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + translation_key="heat_exchanger_efficiency", + value_fn=lambda data: data.heat_exchanger_efficiency, + ), + FlexitSensorEntityDescription( + key="heat_exchanger_speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + translation_key="heat_exchanger_speed", + value_fn=lambda data: data.heat_exchanger_speed, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) sensor from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class FlexitSensor(FlexitEntity, SensorEntity): + """Representation of a Flexit (bacnet) Sensor.""" + + entity_description: FlexitSensorEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitSensorEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index fd2725c6403..d9efd1fc411 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -15,5 +15,64 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "air_filter_polluted": { + "name": "Air filter polluted" + } + }, + "sensor": { + "outside_air_temperature": { + "name": "Outside air temperature" + }, + "supply_air_temperature": { + "name": "Supply air temperature" + }, + "exhaust_air_temperature": { + "name": "Exhaust air temperature" + }, + "extract_air_temperature": { + "name": "Extract air temperature" + }, + "room_temperature": { + "name": "Room temperature" + }, + "fireplace_ventilation_remaining_duration": { + "name": "Fireplace ventilation remaining duration" + }, + "rapid_ventilation_remaining_duration": { + "name": "Rapid ventilation remaining duration" + }, + "supply_air_fan_control_signal": { + "name": "Supply air fan control signal" + }, + "supply_air_fan_rpm": { + "name": "Supply air fan" + }, + "exhaust_air_fan_control_signal": { + "name": "Exhaust air fan control signal" + }, + "exhaust_air_fan_rpm": { + "name": "Exhaust air fan" + }, + "electric_heater_power": { + "name": "Electric heater power" + }, + "air_filter_operating_time": { + "name": "Air filter operating time" + }, + "heat_exchanger_efficiency": { + "name": "Heat exchanger efficiency" + }, + "heat_exchanger_speed": { + "name": "Heat exchanger speed" + } + }, + "switch": { + "electric_heater": { + "name": "Electric heater" + } + } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py new file mode 100644 index 00000000000..b3751c90f7d --- /dev/null +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -0,0 +1,100 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +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.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSwitchEntityDescription(SwitchEntityDescription): + """Describes a Flexit switch entity.""" + + is_on_fn: Callable[[FlexitBACnet], bool] + turn_on_fn: Callable[[FlexitBACnet], Awaitable[None]] + turn_off_fn: Callable[[FlexitBACnet], Awaitable[None]] + + +SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( + FlexitSwitchEntityDescription( + key="electric_heater", + translation_key="electric_heater", + icon="mdi:radiator", + is_on_fn=lambda data: data.electric_heater, + turn_on_fn=lambda data: data.enable_electric_heater(), + turn_off_fn=lambda data: data.disable_electric_heater(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) switch from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitSwitch(coordinator, description) for description in SWITCHES + ) + + +class FlexitSwitch(FlexitEntity, SwitchEntity): + """Representation of a Flexit Switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + entity_description: FlexitSwitchEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitSwitchEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) switch.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return value of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn electric heater on.""" + try: + await self.entity_description.turn_on_fn(self.coordinator.data) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn electric heater off.""" + try: + await self.entity_description.turn_off_fn(self.coordinator.data) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index bcc52f512a1..27feb15a97e 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -7,6 +7,7 @@ from typing import Any from aioflo.api import API from aioflo.errors import RequestError +from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,9 +16,11 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER -class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): +class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Flo device object.""" + _failure_count: int = 0 + def __init__( self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str ) -> None: @@ -43,8 +46,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): await self.send_presence_ping() await self._update_device() await self._update_consumption_data() - except RequestError as error: - raise UpdateFailed(error) from error + self._failure_count = 0 + except (RequestError, TimeoutError, JSONDecodeError) as error: + self._failure_count += 1 + if self._failure_count > 3: + raise UpdateFailed(error) from error @property def location_id(self) -> str: diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index a5911af3c8f..3a8718a14e0 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -1,4 +1,6 @@ """The flume integration.""" +from __future__ import annotations + from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException @@ -41,7 +43,9 @@ LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( ) -def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): +def _setup_entry( + hass: HomeAssistant, entry: ConfigEntry +) -> tuple[FlumeAuth, FlumeDeviceList, Session]: """Config entry set up in executor.""" config = entry.data diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index e31519738d1..df2a697ed8d 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,4 +1,6 @@ """Config flow for flume integration.""" +from __future__ import annotations + from collections.abc import Mapping import logging import os @@ -36,7 +38,9 @@ DATA_SCHEMA = vol.Schema( ) -def _validate_input(hass: core.HomeAssistant, data: dict, clear_token_file: bool): +def _validate_input( + hass: core.HomeAssistant, data: dict[str, Any], clear_token_file: bool +) -> FlumeDeviceList: """Validate in the executor.""" flume_token_full_path = hass.config.path( f"{BASE_TOKEN_FILENAME}-{data[CONF_USERNAME]}" @@ -56,8 +60,8 @@ def _validate_input(hass: core.HomeAssistant, data: dict, clear_token_file: bool async def validate_input( - hass: core.HomeAssistant, data: dict, clear_token_file: bool = False -): + hass: core.HomeAssistant, data: dict[str, Any], clear_token_file: bool = False +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -85,11 +89,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Init flume config flow.""" - self._reauth_unique_id = None + self._reauth_unique_id: str | None = 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 the initial step.""" - errors = {} + 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() @@ -111,10 +117,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauth input.""" - errors = {} + errors: dict[str, str] = {} existing_entry = await self.async_set_unique_id(self._reauth_unique_id) + assert existing_entry if user_input is not None: new_data = {**existing_entry.data, CONF_PASSWORD: user_input[CONF_PASSWORD]} try: diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 1f590b0cd16..b5d37b8027f 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any import pyflume -from pyflume import FlumeDeviceList +from pyflume import FlumeAuth, FlumeData, FlumeDeviceList from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,7 +21,7 @@ from .const import ( class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for an individual flume device.""" - def __init__(self, hass: HomeAssistant, flume_device) -> None: + def __init__(self, hass: HomeAssistant, flume_device: FlumeData) -> None: """Initialize the Coordinator.""" super().__init__( hass, @@ -79,7 +79,7 @@ class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for flume notifications.""" - def __init__(self, hass: HomeAssistant, auth) -> None: + def __init__(self, hass: HomeAssistant, auth: FlumeAuth) -> None: """Initialize the Coordinator.""" super().__init__( hass, @@ -88,15 +88,15 @@ class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=NOTIFICATION_SCAN_INTERVAL, ) self.auth = auth - self.active_notifications_by_device: dict = {} - self.notifications: list[dict[str, Any]] + self.active_notifications_by_device: dict[str, set[str]] = {} + self.notifications: list[dict[str, Any]] = [] - def _update_lists(self): + def _update_lists(self) -> None: """Query flume for notification list.""" # Get notifications (read or unread). # The related binary sensors (leak detected, high flow, low battery) # will be active until the notification is deleted in the Flume app. - self.notifications: list[dict[str, Any]] = pyflume.FlumeNotificationList( + self.notifications = pyflume.FlumeNotificationList( self.auth, read=None ).notification_list _LOGGER.debug("Notifications %s", self.notifications) diff --git a/homeassistant/components/flume/entity.py b/homeassistant/components/flume/entity.py index f17e58529c4..a6d13b1f291 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -58,7 +58,7 @@ class FlumeEntity(CoordinatorEntity[_FlumeCoordinatorT]): configuration_url="https://portal.flumewater.com", ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Request an update when added.""" await super().async_added_to_hass() # We do not ask for an update with async_add_entities() diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index fc08fee476c..203c9094b2e 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -12,6 +12,7 @@ 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 StateType from .const import ( DEVICE_SCAN_INTERVAL, @@ -36,6 +37,7 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( translation_key="current_interval", suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="month_to_date", @@ -139,7 +141,7 @@ class FlumeSensor(FlumeEntity[FlumeDeviceDataUpdateCoordinator], SensorEntity): """Representation of the Flume sensor.""" @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" sensor_key = self.entity_description.key if sensor_key not in self.coordinator.flume_device.values: diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index cd979d51457..41a20360ff3 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -1,13 +1,25 @@ """Component for monitoring activity on a folder.""" +from __future__ import annotations + import logging import os +from typing import cast import voluptuous as vol -from watchdog.events import PatternMatchingEventHandler +from watchdog.events import ( + FileClosedEvent, + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, + FileSystemEvent, + FileSystemMovedEvent, + PatternMatchingEventHandler, +) from watchdog.observers import Observer from homeassistant.const import EVENT_HOMEASSISTANT_START, 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 @@ -42,8 +54,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the folder watcher.""" conf = config[DOMAIN] for watcher in conf: - path = watcher[CONF_FOLDER] - patterns = watcher[CONF_PATTERNS] + path: str = watcher[CONF_FOLDER] + patterns: list[str] = watcher[CONF_PATTERNS] if not hass.config.is_allowed_path(path): _LOGGER.error("Folder %s is not valid or allowed", path) return False @@ -52,70 +64,72 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def create_event_handler(patterns, hass): +def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler: """Return the Watchdog EventHandler object.""" - class EventHandler(PatternMatchingEventHandler): - """Class for handling Watcher events.""" - - def __init__(self, patterns, hass): - """Initialise the EventHandler.""" - super().__init__(patterns) - self.hass = hass - - def process(self, event, moved=False): - """On Watcher event, fire HA event.""" - _LOGGER.debug("process(%s)", event) - if not event.is_directory: - folder, file_name = os.path.split(event.src_path) - fireable = { - "event_type": event.event_type, - "path": event.src_path, - "file": file_name, - "folder": folder, - } - - if moved: - dest_folder, dest_file_name = os.path.split(event.dest_path) - fireable.update( - { - "dest_path": event.dest_path, - "dest_file": dest_file_name, - "dest_folder": dest_folder, - } - ) - self.hass.bus.fire( - DOMAIN, - fireable, - ) - - def on_modified(self, event): - """File modified.""" - self.process(event) - - def on_moved(self, event): - """File moved.""" - self.process(event, moved=True) - - def on_created(self, event): - """File created.""" - self.process(event) - - def on_deleted(self, event): - """File deleted.""" - self.process(event) - - def on_closed(self, event): - """File closed.""" - self.process(event) - return EventHandler(patterns, hass) +class EventHandler(PatternMatchingEventHandler): + """Class for handling Watcher events.""" + + def __init__(self, patterns: list[str], hass: HomeAssistant) -> None: + """Initialise the EventHandler.""" + super().__init__(patterns) + self.hass = hass + + def process(self, event: FileSystemEvent, moved: bool = False) -> None: + """On Watcher event, fire HA event.""" + _LOGGER.debug("process(%s)", event) + if not event.is_directory: + folder, file_name = os.path.split(event.src_path) + fireable = { + "event_type": event.event_type, + "path": event.src_path, + "file": file_name, + "folder": folder, + } + + if moved: + event = cast(FileSystemMovedEvent, event) + dest_folder, dest_file_name = os.path.split(event.dest_path) + fireable.update( + { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + ) + self.hass.bus.fire( + DOMAIN, + fireable, + ) + + def on_modified(self, event: FileModifiedEvent) -> None: + """File modified.""" + self.process(event) + + def on_moved(self, event: FileMovedEvent) -> None: + """File moved.""" + self.process(event, moved=True) + + def on_created(self, event: FileCreatedEvent) -> None: + """File created.""" + self.process(event) + + def on_deleted(self, event: FileDeletedEvent) -> None: + """File deleted.""" + self.process(event) + + def on_closed(self, event: FileClosedEvent) -> None: + """File closed.""" + self.process(event) + + class Watcher: """Class for starting Watchdog.""" - def __init__(self, path, patterns, hass): + def __init__(self, path: str, patterns: list[str], hass: HomeAssistant) -> None: """Initialise the watchdog observer.""" self._observer = Observer() self._observer.schedule( @@ -124,11 +138,11 @@ class Watcher: hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - def startup(self, event): + def startup(self, event: Event) -> None: """Start the watcher.""" self._observer.start() - def shutdown(self, event): + def shutdown(self, event: Event) -> None: """Shutdown the watcher.""" self._observer.stop() self._observer.join() diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 47e1afaec7b..6066c85e74e 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -75,7 +75,9 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_AZIMUTH, default=180): vol.All( vol.Coerce(int), vol.Range(min=0, max=360) ), - vol.Required(CONF_MODULES_POWER): vol.Coerce(int), + vol.Required(CONF_MODULES_POWER): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), } ), ) @@ -126,7 +128,7 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): vol.Required( CONF_MODULES_POWER, default=self.config_entry.options[CONF_MODULES_POWER], - ): vol.Coerce(int), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional( CONF_DAMPING_MORNING, default=self.config_entry.options.get( diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index c07ddfd9bfb..6674bff81e0 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -10,9 +10,7 @@ from homeassistant.config_entries import ConfigEntry 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, @@ -23,6 +21,7 @@ from .const import ( SERVICE_PTZ_PRESET, ) from .coordinator import FoscamCoordinator +from .entity import FoscamEntity DIR_UP = "up" DIR_DOWN = "down" @@ -94,7 +93,7 @@ async def async_setup_entry( async_add_entities([HassFoscamCamera(coordinator, config_entry)]) -class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): +class HassFoscamCamera(FoscamEntity, Camera): """An implementation of a Foscam IP camera.""" _attr_has_entity_name = True @@ -106,7 +105,7 @@ class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): config_entry: ConfigEntry, ) -> None: """Initialize a Foscam camera.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry.entry_id) Camera.__init__(self) self._foscam_session = coordinator.session @@ -117,10 +116,6 @@ class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): self._rtsp_port = config_entry.data[CONF_RTSP_PORT] if self._rtsp_port: self._attr_supported_features = CameraEntityFeature.STREAM - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="Foscam", - ) async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py new file mode 100644 index 00000000000..ebcd9574e32 --- /dev/null +++ b/homeassistant/components/foscam/entity.py @@ -0,0 +1,30 @@ +"""Component providing basic support for Foscam IP cameras.""" +from __future__ import annotations + +from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FoscamCoordinator + + +class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): + """Base entity for Foscam camera.""" + + def __init__( + self, + coordinator: FoscamCoordinator, + entry_id: str, + ) -> None: + """Initialize the base Foscam entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="Foscam", + ) + if dev_info := coordinator.data.get("dev_info"): + self._attr_device_info[ATTR_MODEL] = dev_info["productName"] + self._attr_device_info[ATTR_SW_VERSION] = dev_info["firmwareVer"] + self._attr_device_info[ATTR_HW_VERSION] = dev_info["hardwareVer"] diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 5465d524faf..bcfbfdbec28 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -3,70 +3,21 @@ from datetime import timedelta import logging from freebox_api.exceptions import HttpRequestError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - Event, - HomeAssistant, - ServiceCall, -) +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv 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 .const import DOMAIN, PLATFORMS, SERVICE_REBOOT from .router import FreeboxRouter, get_api -FREEBOX_SCHEMA = vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))}, - ), - extra=vol.ALLOW_EXTRA, -) - SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Freebox integration.""" - if DOMAIN in config: - for entry_config in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Freebox", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Freebox entry.""" api = await get_api(hass, entry.data[CONF_HOST]) diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index b5e0258d844..ef7f1ea3899 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -83,7 +83,7 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): ) self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) - async def async_update_signal(self): + async def async_update_signal(self) -> None: """Update name & state.""" self._attr_is_on = self._edit_state( await self.get_home_endpoint_value(self._command_id) @@ -167,7 +167,7 @@ class FreeboxRaidDegradedSensor(BinarySensorEntity): return self._raid["degraded"] @callback - def async_on_demand_update(self): + def async_on_demand_update(self) -> None: """Update state.""" self.async_update_state() self.async_write_ha_state() diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index f5c86ec0bce..96b0f63a92e 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -29,11 +29,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up cameras.""" - router = hass.data[DOMAIN][entry.unique_id] - tracked: set = set() + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + tracked: set[str] = set() @callback - def update_callback(): + def update_callback() -> None: add_entities(hass, router, async_add_entities, tracked) router.listeners.append( @@ -45,9 +45,14 @@ async def async_setup_entry( @callback -def add_entities(hass: HomeAssistant, router, async_add_entities, tracked): +def add_entities( + hass: HomeAssistant, + router: FreeboxRouter, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: """Add new cameras from the router.""" - new_tracked = [] + new_tracked: list[FreeboxCamera] = [] for nodeid, node in router.home_devices.items(): if (node["category"] != FreeboxHomeCategory.CAMERA) or (nodeid in tracked): diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 2260e69cc3c..59b5d65710a 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -98,12 +98,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Import a config entry.""" - return await self.async_step_user(user_input) - async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index f74f6f49ebf..ef5cabda1b6 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -19,12 +19,12 @@ API_VERSION = "v6" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, Platform.DEVICE_TRACKER, Platform.SENSOR, - Platform.BINARY_SENSOR, Platform.SWITCH, - Platform.CAMERA, ] DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 42e028b881e..663acdc1f15 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -103,7 +103,7 @@ class FreeboxDevice(ScannerEntity): return SourceType.ROUTER @callback - def async_on_demand_update(self): + def async_on_demand_update(self) -> None: """Update state.""" self.async_update_state() self.async_write_ha_state() @@ -120,6 +120,6 @@ class FreeboxDevice(ScannerEntity): ) -def icon_for_freebox_device(device) -> str: +def icon_for_freebox_device(device: dict[str, Any]) -> str: """Return a device icon from its type.""" return DEVICE_ICONS.get(device["host_type"], "mdi:help-network") diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index 022528e5ea7..2d75494e281 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -1,6 +1,7 @@ """Support for Freebox base features.""" from __future__ import annotations +from collections.abc import Callable import logging from typing import Any @@ -42,7 +43,7 @@ class FreeboxHomeEntity(Entity): self._available = True self._firmware = node["props"].get("FwVersion") self._manufacturer = "Freebox SAS" - self._remove_signal_update: Any + self._remove_signal_update: Callable[[], None] | None = None self._model = CATEGORY_TO_MODEL.get(node["category"]) if self._model is None: @@ -65,7 +66,7 @@ class FreeboxHomeEntity(Entity): ), ) - async def async_update_signal(self): + async def async_update_signal(self) -> None: """Update signal.""" self._node = self._router.home_devices[self._id] # Update name @@ -77,7 +78,9 @@ class FreeboxHomeEntity(Entity): ) self.async_write_ha_state() - async def set_home_endpoint_value(self, command_id: Any, value=None) -> bool: + async def set_home_endpoint_value( + self, command_id: int | None, value: bool | None = None + ) -> bool: """Set Home endpoint value.""" if command_id is None: _LOGGER.error("Unable to SET a value through the API. Command is None") @@ -97,7 +100,7 @@ class FreeboxHomeEntity(Entity): node = await self._router.home.get_home_endpoint_value(self._id, command_id) return node.get("value") - def get_command_id(self, nodes, ep_type, name) -> int | None: + def get_command_id(self, nodes, ep_type: str, name: str) -> int | None: """Get the command id.""" node = next( filter(lambda x: (x["name"] == name and x["ep_type"] == ep_type), nodes), @@ -110,7 +113,7 @@ class FreeboxHomeEntity(Entity): return None return node["id"] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register state update callback.""" self.remove_signal_update( async_dispatcher_connect( @@ -120,11 +123,12 @@ class FreeboxHomeEntity(Entity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """When entity will be removed from hass.""" - self._remove_signal_update() + if self._remove_signal_update is not None: + self._remove_signal_update() - def remove_signal_update(self, dispacher: Any): + def remove_signal_update(self, dispacher: Callable[[], None]) -> None: """Register state update callback.""" self._remove_signal_update = dispacher diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 765761c43f2..15e3b34bd77 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -1,7 +1,7 @@ """Represent the Freebox router and its devices and sensors.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from datetime import datetime import json @@ -38,7 +38,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def is_json(json_str): +def is_json(json_str: str) -> bool: """Validate if a String is a JSON value or not.""" try: json.loads(json_str) @@ -95,7 +95,7 @@ class FreeboxRouter: self.call_list: list[dict[str, Any]] = [] self.home_granted = True self.home_devices: dict[str, Any] = {} - self.listeners: list[dict[str, Any]] = [] + self.listeners: list[Callable[[], None]] = [] async def update_all(self, now: datetime | None = None) -> None: """Update all Freebox platforms.""" diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index e7547b97d4e..5b6dd494f0b 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -51,7 +51,7 @@ class FreeboxSwitch(SwitchEntity): self._attr_device_info = router.device_info self._attr_unique_id = f"{router.mac} {entity_description.name}" - async def _async_set_state(self, enabled: bool): + async def _async_set_state(self, enabled: bool) -> None: """Turn the switch on or off.""" try: await self._router.wifi.set_global_config({"enabled": enabled}) diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 7a4b0473600..3bb62cb23fb 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -64,10 +64,15 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_current_temperature = 0 _attr_target_temperature = 0 _attr_hvac_mode = HVACMode.OFF + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index bad73d91320..c9acd60b23c 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -179,7 +179,7 @@ class UpdateCoordinatorDataType(TypedDict): class FritzBoxTools( update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] -): +): # pylint: disable=hass-enforce-coordinator-module """FritzBoxTools class.""" def __init__( @@ -315,12 +315,14 @@ class FritzBoxTools( } try: await self.async_scan_devices() - for key, update_fn in self._entity_update_functions.items(): + for key in list(self._entity_update_functions): _LOGGER.debug("update entity %s", key) entity_data["entity_states"][ key ] = await self.hass.async_add_executor_job( - update_fn, self.fritz_status, self.data["entity_states"].get(key) + self._entity_update_functions[key], + self.fritz_status, + self.data["entity_states"].get(key), ) if self.has_call_deflections: entity_data[ @@ -755,7 +757,7 @@ class FritzBoxTools( raise HomeAssistantError("Service not supported") from ex -class AvmWrapper(FritzBoxTools): +class AvmWrapper(FritzBoxTools): # pylint: disable=hass-enforce-coordinator-module """Setup AVM wrapper for API calls.""" async def _async_service_call( diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 5474a171798..03bcc3b77f7 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,12 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -289,12 +294,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): - """Handle a option flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle an options flow.""" async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -308,13 +309,13 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_CONSIDER_HOME, - default=self.config_entry.options.get( + default=self.options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional( CONF_OLD_DISCOVERY, - default=self.config_entry.options.get( + default=self.options.get( CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY ), ): bool, diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 16015ec5837..fb60eaef5f8 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -28,8 +28,8 @@ class MeshRoles(StrEnum): DOMAIN = "fritz" PLATFORMS = [ - Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, Platform.SENSOR, diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index f648d4b3966..8dc19c199a3 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -74,9 +74,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): _attr_precision = PRECISION_HALVES _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float: diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index cb0c8594695..6c06f2cc699 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -91,9 +91,13 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self.data.color_mode == COLOR_MODE: - return ColorMode.HS - return ColorMode.COLOR_TEMP + if self.data.has_color: + if self.data.color_mode == COLOR_MODE: + return ColorMode.HS + return ColorMode.COLOR_TEMP + if self.data.has_level: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF @property def supported_color_modes(self) -> set[ColorMode]: diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index fdf38d88439..5d41f8c12dc 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyfritzhome"], "quality_scale": "gold", - "requirements": ["pyfritzhome==0.6.9"], + "requirements": ["pyfritzhome==0.6.10"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index 75050374e52..a13a86574df 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -14,8 +14,6 @@ class FritzState(StrEnum): DISCONNECT = "DISCONNECT" -ICON_PHONE: Final = "mdi:phone" - ATTR_PREFIXES = "prefixes" FRITZ_ATTR_NAME = "name" diff --git a/homeassistant/components/fritzbox_callmonitor/icons.json b/homeassistant/components/fritzbox_callmonitor/icons.json new file mode 100644 index 00000000000..836d3159681 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "sensor": { + "fritzbox_callmonitor": { + "default": "mdi:phone", + "state": { + "ringing": "mdi:phone-incoming", + "dialing": "mdi:phone-outgoing", + "talking": "mdi:phone-in-talk" + } + } + } + } +} diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index cc239895c38..036c9605d0a 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -26,7 +26,6 @@ from .const import ( CONF_PREFIXES, DOMAIN, FRITZBOX_PHONEBOOK, - ICON_PHONE, MANUFACTURER, SERIAL_NUMBER, FritzState, @@ -56,18 +55,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, @@ -81,14 +78,14 @@ async def async_setup_entry( 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 +100,7 @@ class FritzBoxCallSensor(SensorEntity): 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 ac36942eec2..9bfb1a6a7a0 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -44,6 +44,7 @@ "entity": { "sensor": { "fritzbox_callmonitor": { + "name": "Call monitor {phonebook_name}", "state": { "ringing": "Ringing", "dialing": "Dialing", diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 14892c35aac..09419f2d3bd 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations @@ -344,6 +345,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) + websocket_api.async_register_command(hass, websocket_get_icons) websocket_api.async_register_command(hass, websocket_get_panels) websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) @@ -610,7 +612,8 @@ class IndexView(web_urldispatcher.AbstractResource): else: extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls - return web.Response( + + response = web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], @@ -619,6 +622,8 @@ class IndexView(web_urldispatcher.AbstractResource): ), content_type="text/html", ) + response.enable_compression() + return response def __len__(self) -> int: """Return length of resource.""" @@ -644,6 +649,28 @@ class ManifestJSONView(HomeAssistantView): ) +@websocket_api.websocket_command( + { + "type": "frontend/get_icons", + vol.Required("category"): vol.In({"entity", "entity_component", "services"}), + vol.Optional("integration"): vol.All(cv.ensure_list, [str]), + } +) +@websocket_api.async_response +async def websocket_get_icons( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle get icons command.""" + resources = await async_get_icons( + hass, + msg["category"], + msg.get("integration"), + ) + connection.send_message( + websocket_api.result_message(msg["id"], {"resources": resources}) + ) + + @callback @websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels( diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ad24f6bb12d..d998871a60b 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==20240104.0"] + "requirements": ["home-assistant-frontend==20240207.0"] } diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 557af9474ed..0ec582f8d06 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -1,11 +1,15 @@ """The Global Disaster Alert and Coordination System (GDACS) integration.""" -from datetime import timedelta +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta import logging +from aio_georss_client.status_update import StatusUpdate from aio_georss_gdacs import GdacsFeedManager -import voluptuous as vol +from aio_georss_gdacs.feed_entry import FeedEntry -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -14,71 +18,22 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( +from .const import ( # noqa: F401 CONF_CATEGORIES, - DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED, PLATFORMS, - VALID_CATEGORIES, ) _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_CATEGORIES, default=[]): vol.All( - cv.ensure_list, [vol.In(VALID_CATEGORIES)] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the GDACS component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - latitude = conf.get(CONF_LATITUDE, hass.config.latitude) - longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) - scan_interval = conf[CONF_SCAN_INTERVAL] - categories = conf[CONF_CATEGORIES] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_RADIUS: conf[CONF_RADIUS], - CONF_SCAN_INTERVAL: scan_interval, - CONF_CATEGORIES: categories, - }, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the GDACS component as config entry.""" @@ -100,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an GDACS component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) + manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED].pop(entry.entry_id) await manager.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -108,7 +63,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class GdacsFeedEntityManager: """Feed Entity Manager for GDACS feed.""" - def __init__(self, hass, config_entry, radius_in_km): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, radius_in_km: float + ) -> None: """Initialize the Feed Entity Manager.""" self._hass = hass self._config_entry = config_entry @@ -130,18 +87,18 @@ class GdacsFeedEntityManager: ) self._config_entry_id = config_entry.entry_id self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) - self._track_time_remove_callback = None - self._status_info = None - self.listeners = [] + self._track_time_remove_callback: Callable[[], None] | None = None + self._status_info: StatusUpdate | None = None + self.listeners: list[Callable[[], None]] = [] - async def async_init(self): + async def async_init(self) -> None: """Schedule initial and regular updates based on configured time interval.""" await self._hass.config_entries.async_forward_entry_setups( self._config_entry, PLATFORMS ) - async def update(event_time): + async def update(event_time: datetime) -> None: """Update.""" await self.async_update() @@ -152,12 +109,12 @@ class GdacsFeedEntityManager: _LOGGER.debug("Feed entity manager initialized") - async def async_update(self): + async def async_update(self) -> None: """Refresh data.""" await self._feed_manager.update() _LOGGER.debug("Feed entity manager updated") - async def async_stop(self): + async def async_stop(self) -> None: """Stop this feed entity manager from refreshing.""" for unsub_dispatcher in self.listeners: unsub_dispatcher() @@ -167,19 +124,19 @@ class GdacsFeedEntityManager: _LOGGER.debug("Feed entity manager stopped") @callback - def async_event_new_entity(self): + def async_event_new_entity(self) -> str: """Return manager specific event to signal new entity.""" return f"gdacs_new_geolocation_{self._config_entry_id}" - def get_entry(self, external_id): + def get_entry(self, external_id: str) -> FeedEntry | None: """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) - def status_info(self): + def status_info(self) -> StatusUpdate | None: """Return latest status update info received.""" return self._status_info - async def _generate_entity(self, external_id): + async def _generate_entity(self, external_id: str) -> None: """Generate new entity.""" async_dispatcher_send( self._hass, @@ -189,15 +146,15 @@ class GdacsFeedEntityManager: external_id, ) - async def _update_entity(self, external_id): + async def _update_entity(self, external_id: str) -> None: """Update entity.""" async_dispatcher_send(self._hass, f"gdacs_update_{external_id}") - async def _remove_entity(self, external_id): + async def _remove_entity(self, external_id: str) -> None: """Remove entity.""" async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}") - async def _status_update(self, status_info): + async def _status_update(self, status_info: StatusUpdate) -> None: """Propagate status update.""" _LOGGER.debug("Status update received: %s", status_info) self._status_info = status_info diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py index fb2b8416937..acc3bbc1991 100644 --- a/homeassistant/components/gdacs/config_flow.py +++ b/homeassistant/components/gdacs/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure the GDACS integration.""" import logging +from typing import Any import voluptuous as vol @@ -10,10 +11,8 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_CATEGORIES, DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -27,33 +26,15 @@ _LOGGER = logging.getLogger(__name__) class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a GDACS config flow.""" - 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", data_schema=DATA_SCHEMA, errors=errors or {} ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - result = await self.async_step_user(import_config) - if result["type"] == FlowResultType.CREATE_ENTRY: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - return result - - 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 start of the config flow.""" _LOGGER.debug("User input: %s", user_input) if not user_input: @@ -67,25 +48,7 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) - try: - self._abort_if_unique_id_configured() - except AbortFlow: - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - raise + self._abort_if_unique_id_configured() scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.total_seconds() diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index 551c8be5810..6be7e7b32fc 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform DOMAIN = "gdacs" -PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] +PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] FEED = "feed" diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 1d3dabc464c..5c4fb9d33bc 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -2,10 +2,10 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime import logging from typing import Any -from aio_georss_gdacs import GdacsFeedManager from aio_georss_gdacs.feed_entry import GdacsFeedEntry from homeassistant.components.geo_location import GeolocationEvent @@ -58,7 +58,7 @@ async def async_setup_entry( @callback def async_add_geolocation( - feed_manager: GdacsFeedManager, integration_id: str, external_id: str + feed_manager: GdacsFeedEntityManager, integration_id: str, external_id: str ) -> None: """Add geolocation entity from feed.""" new_entity = GdacsEvent(feed_manager, integration_id, external_id) @@ -83,25 +83,28 @@ class GdacsEvent(GeolocationEvent): _attr_source = SOURCE def __init__( - self, feed_manager: GdacsFeedManager, integration_id: str, external_id: str + self, + feed_manager: GdacsFeedEntityManager, + integration_id: str, + external_id: str, ) -> None: """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager self._external_id = external_id self._attr_unique_id = f"{integration_id}_{external_id}" self._attr_unit_of_measurement = UnitOfLength.KILOMETERS - self._alert_level = None - self._country = None - self._description = None - self._duration_in_week = None - self._event_type_short = None - self._event_type = None - self._from_date = None - self._to_date = None - self._population = None - self._severity = None - self._vulnerability = None - self._version = None + self._alert_level: str | None = None + self._country: str | None = None + self._description: str | None = None + self._duration_in_week: int | None = None + self._event_type_short: str | None = None + self._event_type: str | None = None + self._from_date: datetime | None = None + self._to_date: datetime | None = None + self._population: str | None = None + self._severity: str | None = None + self._vulnerability: str | float | None = None + self._version: int | None = None self._remove_signal_delete: Callable[[], None] self._remove_signal_update: Callable[[], None] diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index b6fb3d8cee3..d743dd00424 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio-georss-gdacs==0.8"] + "requirements": ["aio-georss-gdacs==0.9"] } diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 8a0a0113ced..8039d5274ed 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -2,7 +2,11 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime import logging +from typing import Any + +from aio_georss_client.status_update import StatusUpdate from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -12,6 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import GdacsFeedEntityManager from .const import DEFAULT_ICON, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -34,7 +39,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the GDACS Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] sensor = GdacsSensor(entry, manager) async_add_entities([sensor]) @@ -48,20 +53,22 @@ class GdacsSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, config_entry: ConfigEntry, manager) -> None: + def __init__( + self, config_entry: ConfigEntry, manager: GdacsFeedEntityManager + ) -> 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._manager = manager - self._status = None - self._last_update = None - self._last_update_successful = None - self._last_timestamp = None - self._total = None - self._created = None - self._updated = None - self._removed = None + self._status: str | None = None + self._last_update: datetime | None = None + self._last_update_successful: datetime | None = None + self._last_timestamp: datetime | None = None + self._total: int | None = None + self._created: int | None = None + self._updated: int | None = None + self._removed: int | None = None self._remove_signal_status: Callable[[], None] | None = None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.unique_id)}, @@ -86,7 +93,7 @@ class GdacsSensor(SensorEntity): self._remove_signal_status() @callback - def _update_status_callback(self): + def _update_status_callback(self) -> None: """Call status update method.""" _LOGGER.debug("Received status update for %s", self._config_entry_id) self.async_schedule_update_ha_state(True) @@ -99,7 +106,7 @@ class GdacsSensor(SensorEntity): if status_info: self._update_from_status_info(status_info) - def _update_from_status_info(self, status_info): + def _update_from_status_info(self, status_info: StatusUpdate) -> None: """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( @@ -118,14 +125,14 @@ class GdacsSensor(SensorEntity): self._removed = status_info.removed @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._total @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attributes = {} + attributes: dict[str, Any] = {} for key, value in ( (ATTR_STATUS, self._status), (ATTR_LAST_UPDATE, self._last_update), diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index f4c02a2ab9f..cadc855ade6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -11,25 +11,18 @@ import httpx import voluptuous as vol import yarl -from homeassistant.components.camera import ( - DEFAULT_CONTENT_TYPE, - PLATFORM_SCHEMA, - Camera, - CameraEntityFeature, -) +from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, - RTSP_TRANSPORTS, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant @@ -38,7 +31,6 @@ from homeassistant.helpers import config_validation as cv, template as template_ 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 from . import DOMAIN from .const import ( @@ -47,64 +39,12 @@ from .const import ( CONF_LIMIT_REFETCH_TO_URL_CHANGE, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, - DEFAULT_NAME, GET_IMAGE_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, - vol.Optional(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, - vol.Optional(CONF_FRAMERATE, default=2): vol.Any( - cv.small_float, cv.positive_int - ), - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up a generic IP Camera.""" - - image = config.get(CONF_STILL_IMAGE_URL) - stream = config.get(CONF_STREAM_SOURCE) - config_new = { - CONF_NAME: config[CONF_NAME], - CONF_STILL_IMAGE_URL: image.template if image is not None else None, - CONF_STREAM_SOURCE: stream.template if stream is not None else None, - CONF_AUTHENTICATION: config.get(CONF_AUTHENTICATION), - CONF_USERNAME: config.get(CONF_USERNAME), - CONF_PASSWORD: config.get(CONF_PASSWORD), - CONF_LIMIT_REFETCH_TO_URL_CHANGE: config.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE), - CONF_CONTENT_TYPE: config.get(CONF_CONTENT_TYPE), - CONF_FRAMERATE: config.get(CONF_FRAMERATE), - CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL), - } - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config_new - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -201,6 +141,12 @@ class GenericCamera(Camera): _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) return self._last_image + try: + vol.Schema(vol.Url())(url) + except vol.Invalid as err: + _LOGGER.warning("Invalid URL '%s': %s, returning last image", url, err) + return self._last_image + if url == self._last_url and self._limit_refetch: return self._last_image @@ -239,7 +185,7 @@ class GenericCamera(Camera): return self._last_image @property - def name(self): + def name(self) -> str: """Return the name of this device.""" return self._name diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 67ff5a84ed9..4eb5c3a2a4c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -40,12 +40,11 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -78,8 +77,8 @@ IMAGE_PREVIEWS_ACTIVE = "previews" def build_schema( user_input: Mapping[str, Any], is_options_flow: bool = False, - show_advanced_options=False, -): + show_advanced_options: bool = False, +) -> vol.Schema: """Create schema for camera config setup.""" spec = { vol.Optional( @@ -277,7 +276,7 @@ async def async_test_stream( return {} -def register_preview(hass: HomeAssistant): +def register_preview(hass: HomeAssistant) -> None: """Set up previews for camera feeds during config flow.""" hass.data.setdefault(DOMAIN, {}) @@ -379,47 +378,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=None, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle config import from yaml.""" - - _LOGGER.warning( - "Loading generic IP camera via configuration.yaml is deprecated, " - "it will be automatically imported. Once you have confirmed correct " - "operation, please remove 'generic' (IP camera) section(s) from " - "configuration.yaml" - ) - - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Generic IP Camera", - }, - ) - # abort if we've already got this one. - if self.check_for_existing(import_config): - return self.async_abort(reason="already_exists") - # Don't bother testing the still or stream details on yaml import. - still_url = import_config.get(CONF_STILL_IMAGE_URL) - stream_url = import_config.get(CONF_STREAM_SOURCE) - name = import_config.get( - CONF_NAME, - slug(self.hass, still_url) or slug(self.hass, stream_url) or DEFAULT_NAME, - ) - - if CONF_LIMIT_REFETCH_TO_URL_CHANGE not in import_config: - import_config[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - still_format = import_config.get(CONF_CONTENT_TYPE, "image/jpeg") - import_config[CONF_CONTENT_TYPE] = still_format - return self.async_create_entry(title=name, data={}, options=import_config) - class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 7ac9c5d406f..861e2cf26c2 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.1.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.2.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 3bdecbfa997..095b46245cf 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING, Any from homeassistant.components.humidifier import ( ATTR_HUMIDITY, @@ -27,7 +30,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + Event, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import condition from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -72,22 +81,22 @@ async def async_setup_platform( """Set up the generic hygrostat platform.""" if discovery_info: config = discovery_info - name = config[CONF_NAME] - switch_entity_id = config[CONF_HUMIDIFIER] - sensor_entity_id = config[CONF_SENSOR] - min_humidity = config.get(CONF_MIN_HUMIDITY) - max_humidity = config.get(CONF_MAX_HUMIDITY) - target_humidity = config.get(CONF_TARGET_HUMIDITY) - device_class = config.get(CONF_DEVICE_CLASS) - min_cycle_duration = config.get(CONF_MIN_DUR) - sensor_stale_duration = config.get(CONF_STALE_DURATION) - dry_tolerance = config[CONF_DRY_TOLERANCE] - wet_tolerance = config[CONF_WET_TOLERANCE] - keep_alive = config.get(CONF_KEEP_ALIVE) - initial_state = config.get(CONF_INITIAL_STATE) - away_humidity = config.get(CONF_AWAY_HUMIDITY) - away_fixed = config.get(CONF_AWAY_FIXED) - unique_id = config.get(CONF_UNIQUE_ID) + name: str = config[CONF_NAME] + switch_entity_id: str = config[CONF_HUMIDIFIER] + sensor_entity_id: str = config[CONF_SENSOR] + min_humidity: int | None = config.get(CONF_MIN_HUMIDITY) + max_humidity: int | None = config.get(CONF_MAX_HUMIDITY) + target_humidity: int | None = config.get(CONF_TARGET_HUMIDITY) + device_class: HumidifierDeviceClass | None = config.get(CONF_DEVICE_CLASS) + min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) + sensor_stale_duration: timedelta | None = config.get(CONF_STALE_DURATION) + dry_tolerance: float = config[CONF_DRY_TOLERANCE] + wet_tolerance: float = config[CONF_WET_TOLERANCE] + keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) + initial_state: bool | None = config.get(CONF_INITIAL_STATE) + away_humidity: int | None = config.get(CONF_AWAY_HUMIDITY) + away_fixed: bool | None = config.get(CONF_AWAY_FIXED) + unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -120,28 +129,28 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, - ): + name: str, + switch_entity_id: str, + sensor_entity_id: str, + min_humidity: int | None, + max_humidity: int | None, + target_humidity: int | None, + device_class: HumidifierDeviceClass | None, + min_cycle_duration: timedelta | None, + dry_tolerance: float, + wet_tolerance: float, + keep_alive: timedelta | None, + initial_state: bool | None, + away_humidity: int | None, + away_fixed: bool | None, + sensor_stale_duration: timedelta | None, + unique_id: str | None, + ) -> None: """Initialize the hygrostat.""" self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._device_class = device_class + self._device_class = device_class or HumidifierDeviceClass.HUMIDIFIER self._min_cycle_duration = min_cycle_duration self._dry_tolerance = dry_tolerance self._wet_tolerance = wet_tolerance @@ -149,7 +158,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._state = initial_state self._saved_target_humidity = away_humidity or target_humidity self._active = False - self._cur_humidity = None + self._cur_humidity: float | None = None self._humidity_lock = asyncio.Lock() self._min_humidity = min_humidity self._max_humidity = max_humidity @@ -159,14 +168,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._away_humidity = away_humidity self._away_fixed = away_fixed self._sensor_stale_duration = sensor_stale_duration - self._remove_stale_tracking = None + self._remove_stale_tracking: Callable[[], None] | None = None self._is_away = False - if not self._device_class: - self._device_class = HumidifierDeviceClass.HUMIDIFIER self._attr_action = HumidifierAction.IDLE self._attr_unique_id = unique_id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() @@ -185,7 +192,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) ) - async def _async_startup(event): + async def _async_startup(event: Event | None) -> None: """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) if sensor_state is None or sensor_state.state in ( @@ -234,39 +241,39 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return await super().async_will_remove_from_hass() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._active @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes.""" if self._saved_target_humidity: return {ATTR_SAVED_HUMIDITY: self._saved_target_humidity} return None @property - def name(self): + def name(self) -> str: """Return the name of the hygrostat.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the hygrostat is on.""" return self._state @property - def current_humidity(self): + def current_humidity(self) -> int | None: """Return the measured humidity.""" - return self._cur_humidity + return int(self._cur_humidity) if self._cur_humidity is not None else None @property - def target_humidity(self): + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._target_humidity @property - def mode(self): + def mode(self) -> str | None: """Return the current mode.""" if self._away_humidity is None: return None @@ -275,18 +282,18 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return MODE_NORMAL @property - def available_modes(self): + def available_modes(self) -> list[str] | None: """Return a list of available modes.""" if self._away_humidity: return [MODE_NORMAL, MODE_AWAY] return None @property - def device_class(self): + def device_class(self) -> HumidifierDeviceClass: """Return the device class of the humidifier.""" return self._device_class - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn hygrostat on.""" if not self._active: return @@ -294,7 +301,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_operate(force=True) self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn hygrostat off.""" if not self._active: return @@ -306,7 +313,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if humidity is None: - return + return # type: ignore[unreachable] if self._is_away and self._away_fixed: self._saved_target_humidity = humidity @@ -318,7 +325,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.async_write_ha_state() @property - def min_humidity(self): + def min_humidity(self) -> int: """Return the minimum humidity.""" if self._min_humidity: return self._min_humidity @@ -327,7 +334,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return super().min_humidity @property - def max_humidity(self): + def max_humidity(self) -> int: """Return the maximum humidity.""" if self._max_humidity: return self._max_humidity @@ -335,7 +342,9 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): # Get default humidity from super class return super().max_humidity - async def _async_sensor_changed(self, entity_id, old_state, new_state): + async def _async_sensor_changed( + self, entity_id: str, old_state: State | None, new_state: State | None + ) -> None: """Handle ambient humidity changes.""" if new_state is None: return @@ -353,18 +362,21 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_operate() self.async_write_ha_state() - async def _async_sensor_not_responding(self, now=None): + async def _async_sensor_not_responding(self, now: datetime | None = None) -> None: """Handle sensor stale event.""" + state = self.hass.states.get(self._sensor_entity_id) _LOGGER.debug( "Sensor has not been updated for %s", - now - self.hass.states.get(self._sensor_entity_id).last_updated, + now - state.last_updated if now and state else "---", ) _LOGGER.warning("Sensor is stalled, call the emergency stop") await self._async_update_humidity("Stalled") @callback - def _async_switch_changed(self, entity_id, old_state, new_state): + def _async_switch_changed( + self, entity_id: str, old_state: State | None, new_state: State | None + ) -> None: """Handle humidifier switch state changes.""" if new_state is None: return @@ -379,7 +391,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.async_schedule_update_ha_state() - async def _async_update_humidity(self, humidity): + async def _async_update_humidity(self, humidity: str) -> None: """Update hygrostat with latest state from sensor.""" try: self._cur_humidity = float(humidity) @@ -390,7 +402,9 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if self._is_device_active: await self._async_device_turn_off() - async def _async_operate(self, time=None, force=False): + async def _async_operate( + self, time: datetime | None = None, force: bool = False + ) -> None: """Check if we need to turn humidifying on or off.""" async with self._humidity_lock: if not self._active and None not in ( @@ -432,12 +446,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if force: # Ignore the tolerance when switched on manually - dry_tolerance = 0 - wet_tolerance = 0 + dry_tolerance: float = 0 + wet_tolerance: float = 0 else: dry_tolerance = self._dry_tolerance wet_tolerance = self._wet_tolerance + if TYPE_CHECKING: + assert self._target_humidity is not None + assert self._cur_humidity is not None too_dry = self._target_humidity - self._cur_humidity >= dry_tolerance too_wet = self._cur_humidity - self._target_humidity >= wet_tolerance if self._is_device_active: @@ -461,16 +478,16 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_device_turn_off() @property - def _is_device_active(self): + def _is_device_active(self) -> bool: """If the toggleable device is currently active.""" return self.hass.states.is_state(self._switch_entity_id, STATE_ON) - async def _async_device_turn_on(self): + async def _async_device_turn_on(self) -> None: """Turn humidifier toggleable device on.""" data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) - async def _async_device_turn_off(self): + async def _async_device_turn_off(self) -> None: """Turn humidifier toggleable device off.""" data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index c9fcde87162..3a964204b70 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timedelta import logging import math from typing import Any @@ -36,10 +37,12 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfTemperature, ) from homeassistant.core import ( DOMAIN as HA_DOMAIN, CoreState, + Event, HomeAssistant, State, callback, @@ -126,25 +129,25 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name = config.get(CONF_NAME) - heater_entity_id = config.get(CONF_HEATER) - sensor_entity_id = config.get(CONF_SENSOR) - min_temp = config.get(CONF_MIN_TEMP) - max_temp = config.get(CONF_MAX_TEMP) - target_temp = config.get(CONF_TARGET_TEMP) - ac_mode = config.get(CONF_AC_MODE) - min_cycle_duration = config.get(CONF_MIN_DUR) - cold_tolerance = config.get(CONF_COLD_TOLERANCE) - hot_tolerance = config.get(CONF_HOT_TOLERANCE) - keep_alive = config.get(CONF_KEEP_ALIVE) - initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) - presets = { + name: str = config[CONF_NAME] + heater_entity_id: str = config[CONF_HEATER] + sensor_entity_id: str = config[CONF_SENSOR] + min_temp: float | None = config.get(CONF_MIN_TEMP) + max_temp: float | None = config.get(CONF_MAX_TEMP) + target_temp: float | None = config.get(CONF_TARGET_TEMP) + ac_mode: bool | None = config.get(CONF_AC_MODE) + min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) + cold_tolerance: float = config[CONF_COLD_TOLERANCE] + hot_tolerance: float = config[CONF_HOT_TOLERANCE] + keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) + initial_hvac_mode: HVACMode | None = config.get(CONF_INITIAL_HVAC_MODE) + presets: dict[str, float] = { key: config[value] for key, value in CONF_PRESETS.items() if value in config } - precision = config.get(CONF_PRECISION) - target_temperature_step = config.get(CONF_TEMP_STEP) + precision: float | None = config.get(CONF_PRECISION) + target_temperature_step: float | None = config.get(CONF_TEMP_STEP) unit = hass.config.units.temperature_unit - unique_id = config.get(CONF_UNIQUE_ID) + unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -175,27 +178,28 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Representation of a Generic Thermostat device.""" _attr_should_poll = False + _enable_turn_on_off_backwards_compatibility = False def __init__( self, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, - ): + name: str, + heater_entity_id: str, + sensor_entity_id: str, + min_temp: float | None, + max_temp: float | None, + target_temp: float | None, + ac_mode: bool | None, + min_cycle_duration: timedelta | None, + cold_tolerance: float, + hot_tolerance: float, + keep_alive: timedelta | None, + initial_hvac_mode: HVACMode | None, + presets: dict[str, float], + precision: float | None, + target_temperature_step: float | None, + unit: UnitOfTemperature, + unique_id: str | None, + ) -> None: """Initialize the thermostat.""" self._attr_name = name self.heater_entity_id = heater_entity_id @@ -214,7 +218,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): else: self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] self._active = False - self._cur_temp = None + self._cur_temp: float | None = None self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp @@ -222,7 +226,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp = target_temp self._attr_temperature_unit = unit self._attr_unique_id = unique_id - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) if len(presets): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = [PRESET_NONE] + list(presets.keys()) @@ -254,7 +262,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ) @callback - def _async_startup(*_): + def _async_startup(_: Event | None = None) -> None: """Init on startup.""" sensor_state = self.hass.states.get(self.sensor_entity_id) if sensor_state and sensor_state.state not in ( @@ -270,7 +278,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self.hass.create_task(self._check_switch_initial_state()) - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: _async_startup() else: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) @@ -297,7 +305,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if not self._hvac_mode and old_state.state: - self._hvac_mode = old_state.state + self._hvac_mode = HVACMode(old_state.state) else: # No previous state, try and restore defaults @@ -315,14 +323,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._hvac_mode = HVACMode.OFF @property - def precision(self): + def precision(self) -> float: """Return the precision of the system.""" if self._temp_precision is not None: return self._temp_precision return super().precision @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" if self._temp_target_temperature_step is not None: return self._temp_target_temperature_step @@ -330,17 +338,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return self.precision @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the sensor temperature.""" return self._cur_temp @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode | None: """Return current operation.""" return self._hvac_mode @property - def hvac_action(self): + def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. Need to be one of CURRENT_HVAC_*. @@ -354,7 +362,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return HVACAction.HEATING @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temp @@ -385,7 +393,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.async_write_ha_state() @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" if self._min_temp is not None: return self._min_temp @@ -394,7 +402,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return super().min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" if self._max_temp is not None: return self._max_temp @@ -414,7 +422,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_control_heating() self.async_write_ha_state() - async def _check_switch_initial_state(self): + async def _check_switch_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" if self._hvac_mode == HVACMode.OFF and self._is_device_active: _LOGGER.warning( @@ -448,7 +456,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): except ValueError as ex: _LOGGER.error("Unable to update from sensor: %s", ex) - async def _async_control_heating(self, time=None, force=False): + async def _async_control_heating( + self, time: datetime | None = None, force: bool = False + ) -> None: """Check if we need to turn heating on or off.""" async with self._temp_lock: if not self._active and None not in ( @@ -490,6 +500,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not long_enough: return + assert self._cur_temp is not None and self._target_temp is not None too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance if self._is_device_active: @@ -514,21 +525,21 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_heater_turn_off() @property - def _is_device_active(self): + def _is_device_active(self) -> bool | None: """If the toggleable device is currently active.""" if not self.hass.states.get(self.heater_entity_id): return None return self.hass.states.is_state(self.heater_entity_id, STATE_ON) - async def _async_heater_turn_on(self): + async def _async_heater_turn_on(self) -> None: """Turn heater toggleable device on.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await self.hass.services.async_call( HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context ) - async def _async_heater_turn_off(self): + async def _async_heater_turn_off(self) -> None: """Turn heater toggleable device off.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await self.hass.services.async_call( diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index bafda44501b..cb817c64930 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -50,8 +50,12 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): """Representation of a Genius Hub climate device.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, broker, zone) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 8cb30535e66..134f6a0e943 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -104,7 +104,11 @@ class GeoJsonLocationEvent(GeolocationEvent): def _update_from_feed(self, feed_entry: GenericFeedEntry) -> None: """Update the internal state from the provided feed entry.""" - self._attr_name = feed_entry.title + if feed_entry.properties and "name" in feed_entry.properties: + # The entry name's type can vary, but our own name must be a string + self._attr_name = str(feed_entry.properties["name"]) + else: + self._attr_name = feed_entry.title self._attr_distance = feed_entry.distance_to_home self._attr_latitude = feed_entry.coordinates[0] self._attr_longitude = feed_entry.coordinates[1] diff --git a/homeassistant/components/geo_location/icons.json b/homeassistant/components/geo_location/icons.json new file mode 100644 index 00000000000..0341ec9483e --- /dev/null +++ b/homeassistant/components/geo_location/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:map-marker" + } + } +} diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index bdf8f126680..17640e37278 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], - "requirements": ["georss-generic-client==0.6"] + "requirements": ["georss-generic-client==0.8"] } diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index f3303d551ce..6ec2199f9e4 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -5,7 +5,7 @@ from homeassistant.const import Platform DOMAIN = "geonetnz_quakes" -PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] +PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" CONF_MMI = "mmi" diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 9ed59b2bc97..2314dabcf0f 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], "quality_scale": "platinum", - "requirements": ["aio-geojson-geonetnz-quakes==0.15"] + "requirements": ["aio-geojson-geonetnz-quakes==0.16"] } diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 583b75a24eb..f02e076b66c 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -63,6 +63,7 @@ class GeonetnzVolcanoSensor(SensorEntity): self._config_entry_id = config_entry_id self._feed_manager = feed_manager self._external_id = external_id + self._attr_unique_id = f"{config_entry_id}_{external_id}" self._unit_system = unit_system self._title = None self._distance = None diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 3cdf48944fd..88c505fc4ae 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -74,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold GIOS data.""" def __init__( diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index c90caf0fc89..aa7ec7b6f86 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from contextlib import suppress from typing import TYPE_CHECKING, Any from aiogithubapi import ( @@ -18,7 +17,7 @@ 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, UnknownFlow +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, @@ -124,22 +123,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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: - - 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()) + response = await self._device.activation( + device_code=self._login_device.device_code + ) + self._login = response.data if not self._device: self._device = GitHubDeviceAPI( @@ -174,6 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "url": OAUTH_USER_LOGIN, "code": self._login_device.user_code, }, + progress_task=self.login_task, ) async def async_step_repositories( @@ -220,13 +208,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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/strings.json b/homeassistant/components/github/strings.json index 7b7ae91b9fd..130b404015c 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -9,7 +9,7 @@ } }, "progress": { - "wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n" + "wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```\n" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index bda1baf797a..1c03f8c1dbf 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 @@ PLATFORMS = [Platform.SENSOR] 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 72555b629d7..81d3a118729 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -21,15 +21,8 @@ from homeassistant.const import ( ) 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 @@ DATA_SCHEMA = vol.Schema( 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 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 37da60bdea8..f0477a30463 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -8,9 +8,6 @@ CONF_VERSION = "version" 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/sensor.py b/homeassistant/components/glances/sensor.py index a3578bf6f66..2119e990e44 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -13,15 +13,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - Platform, UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +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 @@ -35,7 +32,6 @@ class GlancesSensorEntityDescriptionMixin: """Mixin for required keys.""" type: str - name_suffix: str @dataclass(frozen=True) @@ -49,7 +45,7 @@ SENSOR_TYPES = { ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", - name_suffix="used percent", + translation_key="disk_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +53,7 @@ SENSOR_TYPES = { ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", - name_suffix="used", + translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -66,7 +62,7 @@ SENSOR_TYPES = { ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", - name_suffix="free", + translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -75,7 +71,7 @@ SENSOR_TYPES = { ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", - name_suffix="RAM used percent", + translation_key="memory_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -83,7 +79,7 @@ SENSOR_TYPES = { ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", - name_suffix="RAM used", + translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -92,7 +88,7 @@ SENSOR_TYPES = { ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", - name_suffix="RAM free", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -101,7 +97,7 @@ SENSOR_TYPES = { ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", - name_suffix="Swap used percent", + translation_key="swap_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -109,7 +105,7 @@ SENSOR_TYPES = { ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", - name_suffix="Swap used", + translation_key="swap_used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -118,7 +114,7 @@ SENSOR_TYPES = { ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", - name_suffix="Swap free", + translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -127,42 +123,42 @@ SENSOR_TYPES = { ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", - name_suffix="CPU load", + translation_key="processor_load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", - name_suffix="Running", + translation_key="process_running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", - name_suffix="Total", + translation_key="process_total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", - name_suffix="Thread", + translation_key="process_threads", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", - name_suffix="Sleeping", + translation_key="process_sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", - name_suffix="CPU used", + translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, @@ -170,7 +166,7 @@ SENSOR_TYPES = { ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", - name_suffix="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -178,7 +174,7 @@ SENSOR_TYPES = { ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", - name_suffix="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -186,7 +182,7 @@ SENSOR_TYPES = { ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", - name_suffix="Fan speed", + translation_key="fan_speed", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, @@ -194,7 +190,7 @@ SENSOR_TYPES = { ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", - name_suffix="Charge", + translation_key="charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, icon="mdi:battery", @@ -203,14 +199,14 @@ SENSOR_TYPES = { ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", - name_suffix="Containers active", + translation_key="container_active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", - name_suffix="Containers CPU used", + translation_key="container_cpu_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, @@ -218,7 +214,7 @@ SENSOR_TYPES = { ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", - name_suffix="Containers RAM used", + translation_key="container_memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:docker", @@ -227,14 +223,14 @@ SENSOR_TYPES = { ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", - name_suffix="Raid available", + translation_key="raid_available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", - name_suffix="Raid used", + translation_key="raid_used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), @@ -249,54 +245,26 @@ async def async_setup_entry( """Set up the Glances sensors.""" coordinator: GlancesDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - name = config_entry.data.get(CONF_NAME) entities = [] - @callback - def _migrate_old_unique_ids( - hass: HomeAssistant, old_unique_id: str, new_key: str - ) -> None: - """Migrate unique IDs to the new format.""" - ent_reg = er.async_get(hass) - - if entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, old_unique_id - ): - ent_reg.async_update_entity( - entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" - ) - for sensor_type, sensors in coordinator.data.items(): if sensor_type in ["fs", "sensors", "raid"]: for sensor_label, params in sensors.items(): for param in params: if sensor_description := SENSOR_TYPES.get((sensor_type, param)): - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", - f"{sensor_label}-{sensor_description.key}", - ) entities.append( GlancesSensor( coordinator, - name, - sensor_label, sensor_description, + sensor_label, ) ) else: for sensor in sensors: if sensor_description := SENSOR_TYPES.get((sensor_type, sensor)): - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_description.name_suffix}", - f"-{sensor_description.key}", - ) entities.append( GlancesSensor( coordinator, - name, - "", sensor_description, ) ) @@ -313,21 +281,23 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit def __init__( self, coordinator: GlancesDataUpdateCoordinator, - name: str | None, - sensor_name_prefix: str, description: GlancesSensorEntityDescription, + sensor_label: str = "", ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._sensor_name_prefix = sensor_name_prefix + self._sensor_label = sensor_label self.entity_description = description - self._attr_name = f"{sensor_name_prefix} {description.name_suffix}".strip() + if sensor_label: + self._attr_translation_placeholders = {"sensor_label": sensor_label} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Glances", - name=name or coordinator.host, + name=coordinator.host, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}" ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property def available(self) -> bool: @@ -346,8 +316,8 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit """Return the state of the resources.""" value = self.coordinator.data[self.entity_description.type] - if isinstance(value.get(self._sensor_name_prefix), dict): + if isinstance(value.get(self._sensor_label), dict): return cast( - StateType, value[self._sensor_name_prefix][self.entity_description.key] + StateType, value[self._sensor_label][self.entity_description.key] ) return cast(StateType, value[self.entity_description.key]) diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 1bab098d65f..972106d352f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -7,7 +7,6 @@ "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%]" }, @@ -30,5 +29,84 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "disk_usage": { + "name": "{sensor_label} disk usage" + }, + "disk_used": { + "name": "{sensor_label} disk used" + }, + "disk_free": { + "name": "{sensor_label} disk free" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_use": { + "name": "Memory use" + }, + "memory_free": { + "name": "Memory free" + }, + "swap_usage": { + "name": "Swap usage" + }, + "swap_use": { + "name": "Swap use" + }, + "swap_free": { + "name": "Swap free" + }, + "cpu_load": { + "name": "CPU load" + }, + "process_running": { + "name": "Running" + }, + "process_total": { + "name": "Total" + }, + "process_threads": { + "name": "Threads" + }, + "process_sleeping": { + "name": "Sleeping" + }, + "cpu_usage": { + "name": "CPU usage" + }, + "temperature": { + "name": "{sensor_label} temperature" + }, + "fan_speed": { + "name": "{sensor_label} fan speed" + }, + "charge": { + "name": "{sensor_label} charge" + }, + "container_active": { + "name": "Containers active" + }, + "container_cpu_usage": { + "name": "Containers CPU usage" + }, + "container_memory_used": { + "name": "Containers memory used" + }, + "raid_available": { + "name": "{sensor_label} available" + }, + "raid_used": { + "name": "{sensor_label} used" + } + } + }, + "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/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index ba1426e1201..093c93699ff 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -47,7 +47,7 @@ class StateData(NamedTuple): class DeviceDataUpdateCoordinator( DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] -): +): # pylint: disable=hass-enforce-coordinator-module """Manages polling for state changes from the device.""" def __init__( diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 88f59ff44f7..eb77eb27106 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -265,7 +265,7 @@ def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: ) -class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): +class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for calendar RPC calls that use an efficient sync.""" config_entry: ConfigEntry @@ -320,7 +320,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): return None -class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): +class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for calendar RPC calls. This sends a polling RPC, not using sync, as a workaround diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 33d913fe8f1..ab38e67479f 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Google integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -68,6 +69,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + _exchange_finished_task: asyncio.Task[bool] | None = None + def __init__(self) -> None: """Set up instance.""" super().__init__() @@ -115,7 +118,7 @@ class OAuth2FlowHandler( if self._web_auth: return await super().async_step_auth(user_input) - if user_input is not None: + if self._exchange_finished_task and self._exchange_finished_task.done(): return self.async_show_progress_done(next_step_id="creation") if not self._device_flow: @@ -150,15 +153,16 @@ class OAuth2FlowHandler( return self.async_abort(reason="oauth_error") self._device_flow = device_flow + exchange_finished_evt = asyncio.Event() + self._exchange_finished_task = self.hass.async_create_task( + exchange_finished_evt.wait() + ) + def _exchange_finished() -> None: self.external_data = { DEVICE_AUTH_CREDS: device_flow.creds } # is None on timeout/expiration - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure( - flow_id=self.flow_id, user_input={} - ) - ) + exchange_finished_evt.set() device_flow.async_set_listener(_exchange_finished) device_flow.async_start_exchange() @@ -170,6 +174,7 @@ class OAuth2FlowHandler( "user_code": self._device_flow.user_code, }, progress_action="exchange", + progress_task=self._exchange_finished_task, ) async def async_step_creation( @@ -210,6 +215,12 @@ class OAuth2FlowHandler( _LOGGER.error("Error reading primary calendar: %s", err) return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(primary_calendar.id) + + if found := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, primary_calendar.id + ): + _LOGGER.debug("Found existing '%s' entry: %s", primary_calendar.id, found) + self._abort_if_unique_id_configured() return self.async_create_entry( title=primary_calendar.id, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index c89925664e0..f3d0d24f7c8 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -316,6 +316,7 @@ class AbstractConfig(ABC): @callback def async_enable_local_sdk(self) -> None: """Enable the local SDK.""" + _LOGGER.debug("async_enable_local_sdk") setup_successful = True setup_webhook_ids = [] @@ -324,11 +325,16 @@ class AbstractConfig(ABC): self._local_sdk_active = False return - for user_agent_id, _ in self._store.agent_user_ids.items(): + for user_agent_id in self._store.agent_user_ids: if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break + _LOGGER.debug( + "Register webhook handler %s for agent user id %s", + webhook_id, + user_agent_id, + ) try: webhook.async_register( self.hass, @@ -360,13 +366,18 @@ class AbstractConfig(ABC): @callback def async_disable_local_sdk(self) -> None: """Disable the local SDK.""" + _LOGGER.debug("async_disable_local_sdk") if not self._local_sdk_active: return for agent_user_id in self._store.agent_user_ids: - webhook.async_unregister( - self.hass, self.get_local_webhook_id(agent_user_id) + webhook_id = self.get_local_webhook_id(agent_user_id) + _LOGGER.debug( + "Unregister webhook handler %s for agent user id %s", + webhook_id, + agent_user_id, ) + webhook.async_unregister(self.hass, webhook_id) self._local_sdk_active = False diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 189d1354e26..bb03e796d91 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -484,6 +484,11 @@ class OnOffTrait(_Trait): if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: return True + if domain == climate.DOMAIN and features & ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ): + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 24b71dd0180..f77931b8d89 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -97,7 +97,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) conversation.async_unset_agent(hass, entry) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index 5ae39c98f3c..a55ff92afe6 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -104,7 +104,7 @@ async def async_send_text_commands( return command_response_list -def default_language_code(hass: HomeAssistant): +def default_language_code(hass: HomeAssistant) -> str: """Get default language code based on Home Assistant config.""" language_code = f"{hass.config.language}-{hass.config.country}" if language_code in SUPPORTED_LANGUAGE_CODES: diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index fa117b579a9..adcd07a0cda 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -24,13 +24,13 @@ LANG_TO_BROADCAST_COMMAND = { } -def broadcast_commands(language_code: str): +def broadcast_commands(language_code: str) -> tuple[str, str]: """Get the commands for broadcasting a message for the given language code. Return type is a tuple where [0] is for broadcasting to your entire home, while [1] is for broadcasting to a specific target. """ - return LANG_TO_BROADCAST_COMMAND.get(language_code.split("-", maxsplit=1)[0]) + return LANG_TO_BROADCAST_COMMAND[language_code.split("-", maxsplit=1)[0]] async def async_get_service( @@ -60,7 +60,7 @@ class BroadcastNotificationService(BaseNotificationService): CONF_LANGUAGE_CODE, default_language_code(self.hass) ) - commands = [] + commands: list[str] = [] targets = kwargs.get(ATTR_TARGET) if not targets: commands.append(broadcast_commands(language_code)[0].format(message)) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c507e0c046d..a522eeab5cd 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,44 +3,122 @@ from __future__ import annotations from functools import partial import logging +import mimetypes +from pathlib import Path from typing import Literal from google.api_core.exceptions import ClientError -import google.generativeai as palm -from google.generativeai.types.discuss_types import ChatResponse +import google.generativeai as genai +import google.generativeai.types as genai_types +import voluptuous as vol from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, MATCH_ALL -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import intent, template +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + TemplateError, +) +from homeassistant.helpers import config_validation as cv, intent, template +from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid from .const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) +SERVICE_GENERATE_CONTENT = "generate_content" +CONF_IMAGE_FILENAME = "image_filename" + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Google Generative AI Conversation.""" + + async def generate_content(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + prompt_parts = [call.data[CONF_PROMPT]] + image_filenames = call.data[CONF_IMAGE_FILENAME] + for image_filename in image_filenames: + if not hass.config.is_allowed_path(image_filename): + raise HomeAssistantError( + f"Cannot read `{image_filename}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + ) + if not Path(image_filename).exists(): + raise HomeAssistantError(f"`{image_filename}` does not exist") + mime_type, _ = mimetypes.guess_type(image_filename) + if mime_type is None or not mime_type.startswith("image"): + raise HomeAssistantError(f"`{image_filename}` is not an image") + prompt_parts.append( + { + "mime_type": mime_type, + "data": await hass.async_add_executor_job( + Path(image_filename).read_bytes + ), + } + ) + + model_name = "gemini-pro-vision" if image_filenames else "gemini-pro" + model = genai.GenerativeModel(model_name=model_name) + + try: + response = await model.generate_content_async(prompt_parts) + except ( + ClientError, + ValueError, + genai_types.BlockedPromptException, + genai_types.StopCandidateException, + ) as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + + return {"text": response.text} + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_CONTENT, + generate_content, + schema=vol.Schema( + { + vol.Required(CONF_PROMPT): cv.string, + vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Generative AI Conversation from a config entry.""" - palm.configure(api_key=entry.data[CONF_API_KEY]) + genai.configure(api_key=entry.data[CONF_API_KEY]) try: await hass.async_add_executor_job( partial( - palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + genai.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) ) ) except ClientError as err: @@ -55,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload GoogleGenerativeAI.""" - palm.configure(api_key=None) + genai.configure(api_key=None) conversation.async_unset_agent(hass, entry) return True @@ -67,7 +145,7 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, list[dict]] = {} + self.history: dict[str, list[genai_types.ContentType]] = {} @property def supported_languages(self) -> list[str] | Literal["*"]: @@ -79,17 +157,27 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) - top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) - top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K) + model = genai.GenerativeModel( + model_name=self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL), + generation_config={ + "temperature": self.entry.options.get( + CONF_TEMPERATURE, DEFAULT_TEMPERATURE + ), + "top_p": self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P), + "top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K), + "max_output_tokens": self.entry.options.get( + CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS + ), + }, + ) + _LOGGER.debug("Model: %s", model) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() - messages = [] + messages = [{}, {}] try: prompt = self._async_generate_prompt(raw_prompt) @@ -104,20 +192,21 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): response=intent_response, conversation_id=conversation_id ) - messages.append({"author": "0", "content": user_input.text}) + messages[0] = {"role": "user", "parts": prompt} + messages[1] = {"role": "model", "parts": "Ok"} - _LOGGER.debug("Prompt for %s: %s", model, messages) + _LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) + chat = model.start_chat(history=messages) try: - chat_response: ChatResponse = await palm.chat_async( - model=model, - context=prompt, - messages=messages, - temperature=temperature, - top_p=top_p, - top_k=top_k, - ) - except ClientError as err: + chat_response = await chat.send_message_async(user_input.text) + except ( + ClientError, + ValueError, + genai_types.BlockedPromptException, + genai_types.StopCandidateException, + ) as err: + _LOGGER.error("Error sending message: %s", err) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, @@ -127,14 +216,11 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): response=intent_response, conversation_id=conversation_id ) - _LOGGER.debug("Response %s", chat_response) - # For some queries the response is empty. In that case don't update history to avoid - # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating" - if chat_response.last: - self.history[conversation_id] = chat_response.messages + _LOGGER.debug("Response: %s", chat_response.parts) + self.history[conversation_id] = chat.history intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(chat_response.last) + intent_response.async_set_speech(chat_response.text) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index fea023c604e..74ba3c478df 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -8,7 +8,7 @@ from types import MappingProxyType from typing import Any from google.api_core.exceptions import ClientError -import google.generativeai as palm +import google.generativeai as genai import voluptuous as vol from homeassistant import config_entries @@ -23,11 +23,13 @@ from homeassistant.helpers.selector import ( from .const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, @@ -50,6 +52,7 @@ DEFAULT_OPTIONS = types.MappingProxyType( CONF_TEMPERATURE: DEFAULT_TEMPERATURE, CONF_TOP_P: DEFAULT_TOP_P, CONF_TOP_K: DEFAULT_TOP_K, + CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, } ) @@ -59,8 +62,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. """ - palm.configure(api_key=data[CONF_API_KEY]) - await hass.async_add_executor_job(partial(palm.list_models)) + genai.configure(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(partial(genai.list_models)) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -162,4 +165,9 @@ def google_generative_ai_config_option_schema( description={"suggested_value": options[CONF_TOP_K]}, default=DEFAULT_TOP_K, ): int, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 9664552e436..2798b85f308 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -24,10 +24,12 @@ Answer the user's questions about the world truthfully. If the user wants to control a device, reject the request and suggest using the Home Assistant app. """ CONF_CHAT_MODEL = "chat_model" -DEFAULT_CHAT_MODEL = "models/chat-bison-001" +DEFAULT_CHAT_MODEL = "models/gemini-pro" CONF_TEMPERATURE = "temperature" -DEFAULT_TEMPERATURE = 0.25 +DEFAULT_TEMPERATURE = 0.9 CONF_TOP_P = "top_p" -DEFAULT_TOP_P = 0.95 +DEFAULT_TOP_P = 1.0 CONF_TOP_K = "top_k" -DEFAULT_TOP_K = 40 +DEFAULT_TOP_K = 1 +CONF_MAX_TOKENS = "max_tokens" +DEFAULT_MAX_TOKENS = 150 diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml new file mode 100644 index 00000000000..f35697b89f8 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/services.yaml @@ -0,0 +1,11 @@ +generate_content: + fields: + prompt: + required: true + selector: + text: + multiline: true + image_filename: + required: false + selector: + object: diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 2b1b41a2c28..306072f33a8 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -21,7 +21,26 @@ "model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", - "top_k": "Top K" + "top_k": "Top K", + "max_tokens": "Maximum tokens to return in response" + } + } + } + }, + "services": { + "generate_content": { + "name": "Generate content", + "description": "Generate content from a prompt consisting of text and optionally images", + "fields": { + "prompt": { + "name": "Prompt", + "description": "The prompt", + "example": "Describe what you see in these images" + }, + "image_filename": { + "name": "Image filename", + "description": "Images", + "example": "/config/www/image.jpg" } } } diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 96639e4a547..311af064fcd 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -64,7 +64,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) return unload_ok diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 590c7bd0c90..ba2a0884e22 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -81,7 +81,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) return True diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 45288e81996..7774d9fd6c8 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -77,17 +77,17 @@ class GoogleTTSEntity(TextToSpeechEntity): self._attr_unique_id = config_entry.entry_id @property - def default_language(self): + def default_language(self) -> str: """Return the default language.""" return self._lang @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return SUPPORT_OPTIONS @@ -120,7 +120,7 @@ class GoogleTTSEntity(TextToSpeechEntity): class GoogleProvider(Provider): """The Google speech API provider.""" - def __init__(self, hass, lang, tld): + def __init__(self, hass: HomeAssistant, lang: str, tld: str) -> None: """Init Google TTS service.""" self.hass = hass if lang in MAP_LANG_TLD: @@ -132,21 +132,23 @@ class GoogleProvider(Provider): self.name = "Google" @property - def default_language(self): + def default_language(self) -> str: """Return the default language.""" return self._lang @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: """Load TTS from google.""" tld = self._tld if language in MAP_LANG_TLD: diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index ec8187d91af..73a4bf87b7e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -192,6 +192,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except InvalidApiKeyException: errors["base"] = "invalid_auth" + except TimeoutError: + errors["base"] = "timeout_connect" except UnknownException: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 12394a23209..9c25d02b8a5 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -35,7 +35,7 @@ def validate_config_entry( raise UnknownException() from transport_error except Timeout as timeout_error: _LOGGER.error("Timeout error") - raise UnknownException() from timeout_error + raise TimeoutError() from timeout_error class InvalidApiKeyException(Exception): diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 06a50dab854..95eb965a4ff 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -93,7 +93,7 @@ class GoogleTravelTimeSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Handle when entity is added.""" - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.first_update ) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index e3a13a3d2e3..3cfcd3cedb3 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -14,7 +14,8 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 5c47f116ce5..64feedc44c1 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -14,6 +14,11 @@ "local_name": "B5178*", "connectable": false }, + { + "manufacturer_id": 1, + "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", + "connectable": false + }, { "manufacturer_id": 6966, "service_uuid": "00008451-0000-1000-8000-00805f9b34fb", @@ -85,5 +90,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.24.0"] + "requirements": ["govee-ble==0.31.0"] } diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index cbef769bdc9..3809a2390f3 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature, @@ -58,6 +59,15 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + ( + DeviceClass.PM25, + Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ): SensorEntityDescription( + key=f"{DeviceClass.PM25}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..ab20f4cefcd --- /dev/null +++ b/homeassistant/components/govee_light_local/__init__.py @@ -0,0 +1,44 @@ +"""The Govee Light local integration.""" +from __future__ import annotations + +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import GoveeLocalApiCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Govee light local from a config entry.""" + + coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass) + entry.async_on_unload(coordinator.cleanup) + + await coordinator.start() + + await coordinator.async_config_entry_first_refresh() + + try: + async with asyncio.timeout(delay=5): + while not coordinator.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady from ex + + 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/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py new file mode 100644 index 00000000000..8ab14966828 --- /dev/null +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Govee light local.""" + +from __future__ import annotations + +import asyncio +import logging + +from govee_local_api import GoveeController + +from homeassistant.components import network +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import ( + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP) + + controller: GoveeController = GoveeController( + loop=hass.loop, + logger=_LOGGER, + listening_address=adapter, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=1, + update_enabled=False, + ) + + await controller.start() + + try: + async with asyncio.timeout(delay=5): + while not controller.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError: + _LOGGER.debug("No devices found") + + devices_count = len(controller.devices) + controller.cleanup() + + return devices_count > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Govee light local", _async_has_devices +) diff --git a/homeassistant/components/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py new file mode 100644 index 00000000000..d9410c9c05e --- /dev/null +++ b/homeassistant/components/govee_light_local/const.py @@ -0,0 +1,13 @@ +"""Constants for the Govee light local integration.""" + +from datetime import timedelta + +DOMAIN = "govee_light_local" +MANUFACTURER = "Govee" + +CONF_MULTICAST_ADDRESS_DEFAULT = "239.255.255.250" +CONF_TARGET_PORT_DEFAULT = 4001 +CONF_LISTENING_PORT_DEFAULT = 4002 +CONF_DISCOVERY_INTERVAL_DEFAULT = 60 + +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py new file mode 100644 index 00000000000..79b572e89ae --- /dev/null +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -0,0 +1,90 @@ +"""Coordinator for Govee light local.""" + +from collections.abc import Callable +import logging + +from govee_local_api import GoveeController, GoveeDevice + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_DISCOVERY_INTERVAL_DEFAULT, + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): + """Govee light local coordinator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize my coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name="GoveeLightLocalApi", + update_interval=SCAN_INTERVAL, + ) + + self._controller = GoveeController( + loop=hass.loop, + logger=_LOGGER, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT, + discovered_callback=None, + update_enabled=False, + ) + + async def start(self) -> None: + """Start the Govee coordinator.""" + await self._controller.start() + self._controller.send_update_message() + + async def set_discovery_callback( + self, callback: Callable[[GoveeDevice, bool], bool] + ) -> None: + """Set discovery callback for automatic Govee light discovery.""" + self._controller.set_device_discovered_callback(callback) + + def cleanup(self) -> None: + """Stop and cleanup the cooridinator.""" + self._controller.cleanup() + + async def turn_on(self, device: GoveeDevice) -> None: + """Turn on the light.""" + await device.turn_on() + + async def turn_off(self, device: GoveeDevice) -> None: + """Turn off the light.""" + await device.turn_off() + + async def set_brightness(self, device: GoveeDevice, brightness: int) -> None: + """Set light brightness.""" + await device.set_brightness(brightness) + + async def set_rgb_color( + self, device: GoveeDevice, red: int, green: int, blue: int + ) -> None: + """Set light RGB color.""" + await device.set_rgb_color(red, green, blue) + + async def set_temperature(self, device: GoveeDevice, temperature: int) -> None: + """Set light color in kelvin.""" + await device.set_temperature(temperature) + + @property + def devices(self) -> list[GoveeDevice]: + """Return a list of discovered Govee devices.""" + return self._controller.devices + + async def _async_update_data(self) -> list[GoveeDevice]: + self._controller.send_update_message() + return self._controller.devices diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py new file mode 100644 index 00000000000..836f48d2ea9 --- /dev/null +++ b/homeassistant/components/govee_light_local/light.py @@ -0,0 +1,162 @@ +"""Govee light local.""" + +from __future__ import annotations + +import logging +from typing import Any + +from govee_local_api import GoveeDevice, GoveeLightCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + filter_supported_color_modes, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import GoveeLocalApiCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Govee light setup.""" + + coordinator: GoveeLocalApiCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + def discovery_callback(device: GoveeDevice, is_new: bool) -> bool: + if is_new: + async_add_entities([GoveeLight(coordinator, device)]) + return True + + async_add_entities( + GoveeLight(coordinator, device) for device in coordinator.devices + ) + + await coordinator.set_discovery_callback(discovery_callback) + + +class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): + """Govee Light.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supported_color_modes: set[ColorMode] + _fixed_color_mode: ColorMode | None = None + + def __init__( + self, + coordinator: GoveeLocalApiCoordinator, + device: GoveeDevice, + ) -> None: + """Govee Light constructor.""" + + super().__init__(coordinator) + self._device = device + device.set_update_callback(self._update_callback) + + self._attr_unique_id = device.fingerprint + + capabilities = device.capabilities + color_modes = {ColorMode.ONOFF} + if capabilities: + if GoveeLightCapability.COLOR_RGB in capabilities: + color_modes.add(ColorMode.RGB) + if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + color_modes.add(ColorMode.COLOR_TEMP) + self._attr_max_color_temp_kelvin = 9000 + self._attr_min_color_temp_kelvin = 2000 + if GoveeLightCapability.BRIGHTNESS in capabilities: + color_modes.add(ColorMode.BRIGHTNESS) + + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, device.fingerprint) + }, + name=device.sku, + manufacturer=MANUFACTURER, + model=device.sku, + connections={(CONNECTION_NETWORK_MAC, device.fingerprint)}, + ) + + @property + def is_on(self) -> bool: + """Return true if device is on (brightness above 0).""" + return self._device.on + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._device.brightness / 100.0) * 255.0) + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self._device.temperature_color + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color.""" + return self._device.rgb_color + + @property + def color_mode(self) -> ColorMode | str | None: + """Return the color mode.""" + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode + + # The light supports both color temperature and RGB, determine which + # mode the light is in + if ( + self._device.temperature_color is not None + and self._device.temperature_color > 0 + ): + return ColorMode.COLOR_TEMP + return ColorMode.RGB + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + + if ATTR_BRIGHTNESS in kwargs: + brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) + await self.coordinator.set_brightness(self._device, brightness) + + if ATTR_RGB_COLOR in kwargs: + self._attr_color_mode = ColorMode.RGB + red, green, blue = kwargs[ATTR_RGB_COLOR] + await self.coordinator.set_rgb_color(self._device, red, green, blue) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + self._attr_color_mode = ColorMode.COLOR_TEMP + temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] + await self.coordinator.set_temperature(self._device, int(temperature)) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.coordinator.turn_off(self._device) + self.async_write_ha_state() + + @callback + def _update_callback(self, device: GoveeDevice) -> None: + self.async_write_ha_state() diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json new file mode 100644 index 00000000000..f34fd0b899f --- /dev/null +++ b/homeassistant/components/govee_light_local/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "govee_light_local", + "name": "Govee lights local", + "codeowners": ["@Galorhallen"], + "config_flow": true, + "dependencies": ["network"], + "documentation": "https://www.home-assistant.io/integrations/govee_light_local", + "iot_class": "local_push", + "requirements": ["govee-local-api==1.4.1"] +} diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/govee_light_local/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/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py index 71656d4d13d..bdd5ddb13b0 100644 --- a/homeassistant/components/gpsd/__init__.py +++ b/homeassistant/components/gpsd/__init__.py @@ -1 +1,19 @@ -"""The gpsd component.""" +"""The GPSD 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.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GPSD 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/gpsd/config_flow.py b/homeassistant/components/gpsd/config_flow.py new file mode 100644 index 00000000000..db1f9c5b0c1 --- /dev/null +++ b/homeassistant/components/gpsd/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for GPSD integration.""" +from __future__ import annotations + +import socket +from typing import Any + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT, HOST as DEFAULT_HOST +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class GPSDConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for GPSD.""" + + VERSION = 1 + + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_data) + + 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._async_abort_entries_match(user_input) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((user_input[CONF_HOST], user_input[CONF_PORT])) + sock.shutdown(2) + except OSError: + return self.async_abort(reason="cannot_connect") + + port = "" + if user_input[CONF_PORT] != DEFAULT_PORT: + port = f":{user_input[CONF_PORT]}" + + return self.async_create_entry( + title=user_input.get(CONF_NAME, f"GPS {user_input[CONF_HOST]}{port}"), + data=user_input, + ) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) diff --git a/homeassistant/components/gpsd/const.py b/homeassistant/components/gpsd/const.py new file mode 100644 index 00000000000..8a2aec140b5 --- /dev/null +++ b/homeassistant/components/gpsd/const.py @@ -0,0 +1,3 @@ +"""Constants for the GPSD integration.""" + +DOMAIN = "gpsd" diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json index d202a6b0428..3f22c5bfab2 100644 --- a/homeassistant/components/gpsd/manifest.json +++ b/homeassistant/components/gpsd/manifest.json @@ -1,7 +1,8 @@ { "domain": "gpsd", "name": "GPSD", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@jrieger"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gpsd", "iot_class": "local_polling", "loggers": ["gps3"], diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 64b86434c3c..932db081598 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -2,13 +2,21 @@ from __future__ import annotations import logging -import socket from typing import Any -from gps3.agps3threaded import AGPS3mechanism +from gps3.agps3threaded import ( + GPSD_PORT as DEFAULT_PORT, + HOST as DEFAULT_HOST, + AGPS3mechanism, +) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -17,11 +25,15 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" @@ -29,9 +41,7 @@ ATTR_ELEVATION = "elevation" ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "GPS" -DEFAULT_PORT = 2947 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -42,71 +52,84 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the GPSD component.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - port = config[CONF_PORT] + async_add_entities( + [ + GpsdSensor( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.entry_id, + ) + ] + ) - # Will hopefully be possible with the next gps3 update - # https://github.com/wadda/gps3/issues/11 - # from gps3 import gps3 - # try: - # gpsd_socket = gps3.GPSDSocket() - # gpsd_socket.connect(host=host, port=port) - # except GPSError: - # _LOGGER.warning('Not able to connect to GPSD') - # return False - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((host, port)) - sock.shutdown(2) - _LOGGER.debug("Connection to GPSD possible") - except OSError: - _LOGGER.error("Not able to connect to GPSD") - return +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Initialize gpsd import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.9.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GPSD", + }, + ) - add_entities([GpsdSensor(hass, name, host, port)]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "mode" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["2d_fix", "3d_fix"] + def __init__( self, - hass: HomeAssistant, - name: str, host: str, port: int, + unique_id: str, ) -> None: """Initialize the GPSD sensor.""" - self.hass = hass - self._name = name - self._host = host - self._port = port + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_unique_id = f"{unique_id}-mode" self.agps_thread = AGPS3mechanism() - self.agps_thread.stream_data(host=self._host, port=self._port) + self.agps_thread.stream_data(host=host, port=port) self.agps_thread.run_thread() - @property - def name(self) -> str: - """Return the name.""" - return self._name - @property def native_value(self) -> str | None: """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: - return "3D Fix" + return "3d_fix" if self.agps_thread.data_stream.mode == 2: - return "2D Fix" + return "2d_fix" return None @property diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json new file mode 100644 index 00000000000..20dc283a8bb --- /dev/null +++ b/homeassistant/components/gpsd/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of GPSD." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "mode": { + "state": { + "2d_fix": "2D Fix", + "3d_fix": "3D Fix" + }, + "state_attributes": { + "latitude": { "name": "[%key:common::config_flow::data::latitude%]" }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + }, + "elevation": { "name": "Elevation" }, + "gps_time": { "name": "Time" }, + "speed": { "name": "Speed" }, + "climb": { "name": "Climb" }, + "mode": { "name": "Mode" } + } + } + } + } +} diff --git a/homeassistant/components/gree/bridge.py b/homeassistant/components/gree/bridge.py index 6628f7fc32c..ebd5e78a820 100644 --- a/homeassistant/components/gree/bridge.py +++ b/homeassistant/components/gree/bridge.py @@ -23,7 +23,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class DeviceDataUpdateCoordinator(DataUpdateCoordinator): +class DeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Manages polling for state changes from the device.""" def __init__(self, hass: HomeAssistant, device: Device) -> None: diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 8d50cdf2aed..1d061c06901 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -113,6 +113,8 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] @@ -120,6 +122,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index a2a61b3016a..894a20629ee 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -3,10 +3,10 @@ from __future__ import annotations 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 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_ICON, CONF_NAME, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, SERVICE_RELOAD, STATE_OFF, STATE_ON, @@ -41,6 +39,10 @@ from homeassistant.helpers.event import ( 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 diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index d1e91db8f86..d63dcb5e8f2 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -89,7 +89,7 @@ async def async_setup_entry( @callback def async_create_preview_binary_sensor( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> BinarySensorGroup: """Create a preview sensor.""" return BinarySensorGroup( diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 93160b0db5b..488f5e131f3 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -269,7 +269,7 @@ PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} CREATE_PREVIEW_ENTITY: dict[ str, - Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup], + Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup], ] = { "binary_sensor": async_create_preview_binary_sensor, "cover": async_create_preview_cover, @@ -392,7 +392,9 @@ def ws_start_preview( ) ) - preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) + preview_entity: GroupEntity | MediaPlayerGroup = CREATE_PREVIEW_ENTITY[group_type]( + hass, name, validated + ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index d22184c0922..78d29378076 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -97,7 +97,7 @@ async def async_setup_entry( @callback def async_create_preview_cover( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> CoverGroup: """Create a preview sensor.""" return CoverGroup( diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index ca0c88867fe..b98991e13fc 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -90,7 +90,7 @@ async def async_setup_entry( @callback def async_create_preview_event( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> EventGroup: """Create a preview sensor.""" return EventGroup( diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4e3bb824266..afd240c5767 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -91,7 +91,9 @@ async def async_setup_entry( @callback -def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup: +def async_create_preview_fan( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> FanGroup: """Create a preview sensor.""" return FanGroup( None, diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 3c1ad7f0d57..5a113491891 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -112,7 +112,7 @@ async def async_setup_entry( @callback def async_create_preview_light( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> LightGroup: """Create a preview sensor.""" return LightGroup( diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 5558eab5475..4a6fdc3e2ed 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -91,7 +91,9 @@ async def async_setup_entry( @callback -def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup: +def async_create_preview_lock( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> LockGroup: """Create a preview sensor.""" return LockGroup( None, diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index b85fbf32a0d..aa38f364d93 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -109,7 +109,7 @@ async def async_setup_entry( @callback def async_create_preview_media_player( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> MediaPlayerGroup: """Create a preview sensor.""" return MediaPlayerGroup( diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index c35c96d38aa..47695a275fc 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,5 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" + from __future__ import annotations from collections.abc import Callable @@ -13,10 +14,12 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, + UNIT_CONVERTERS, SensorDeviceClass, SensorEntity, SensorStateClass, @@ -34,11 +37,22 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity import ( + get_capability, + get_device_class, + get_unit_of_measurement, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import GroupEntity +from . import DOMAIN as GROUP_DOMAIN, GroupEntity from .const import CONF_IGNORE_NON_NUMERIC DEFAULT_NAME = "Sensor Group" @@ -97,6 +111,7 @@ async def async_setup_platform( async_add_entities( [ SensorGroup( + hass, config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES], @@ -123,6 +138,7 @@ async def async_setup_entry( async_add_entities( [ SensorGroup( + hass, config_entry.entry_id, config_entry.title, entities, @@ -138,10 +154,11 @@ async def async_setup_entry( @callback def async_create_preview_sensor( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> SensorGroup: """Create a preview sensor.""" return SensorGroup( + hass, None, name, validated_config[CONF_ENTITIES], @@ -280,30 +297,32 @@ class SensorGroup(GroupEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, unique_id: str | None, name: str, entity_ids: list[str], - mode: bool, + ignore_non_numeric: bool, sensor_type: str, unit_of_measurement: str | None, state_class: SensorStateClass | None, device_class: SensorDeviceClass | None, ) -> None: """Initialize a sensor group.""" + self.hass = hass self._entity_ids = entity_ids self._sensor_type = sensor_type - self._attr_state_class = state_class - self.calc_state_class: SensorStateClass | None = None - self._attr_device_class = device_class - self.calc_device_class: SensorDeviceClass | None = None - self._attr_native_unit_of_measurement = unit_of_measurement - self.calc_unit_of_measurement: str | None = None + self._state_class = state_class + self._device_class = device_class + self._native_unit_of_measurement = unit_of_measurement + self._valid_units: set[str | None] = set() + self._can_convert: bool = False self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id - self.mode = all if mode is False else any + self._ignore_non_numeric = ignore_non_numeric + self.mode = all if ignore_non_numeric is False else any self._state_calc: Callable[ [list[tuple[str, float, State]]], tuple[dict[str, str | None], float | None], @@ -311,6 +330,16 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} + async def async_added_to_hass(self) -> None: + """When added to hass.""" + self._attr_state_class = self._calculate_state_class(self._state_class) + self._attr_device_class = self._calculate_device_class(self._device_class) + self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement( + self._native_unit_of_measurement + ) + self._valid_units = self._get_valid_units() + await super().async_added_to_hass() + @callback def async_update_group_state(self) -> None: """Query all members and determine the sensor group state.""" @@ -321,21 +350,61 @@ class SensorGroup(GroupEntity, SensorEntity): if (state := self.hass.states.get(entity_id)) is not None: states.append(state.state) try: - sensor_values.append((entity_id, float(state.state), state)) + numeric_state = float(state.state) + if ( + self._valid_units + and (uom := state.attributes["unit_of_measurement"]) + in self._valid_units + and self._can_convert is True + ): + numeric_state = UNIT_CONVERTERS[self.device_class].convert( + numeric_state, uom, self.native_unit_of_measurement + ) + if ( + self._valid_units + and (uom := state.attributes["unit_of_measurement"]) + not in self._valid_units + ): + raise HomeAssistantError("Not a valid unit") + + sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) + valid_states.append(True) except ValueError: + valid_states.append(False) + # Log invalid states unless ignoring non numeric values + if ( + not self._ignore_non_numeric + and entity_id not in self._state_incorrect + ): + self._state_incorrect.add(entity_id) + _LOGGER.warning( + "Unable to use state. Only numerical states are supported," + " entity %s with value %s excluded from calculation in %s", + entity_id, + state.state, + self.entity_id, + ) + continue + except (KeyError, HomeAssistantError): + # This exception handling can be simplified + # once sensor entity doesn't allow incorrect unit of measurement + # with a device class, implementation see PR #107639 valid_states.append(False) if entity_id not in self._state_incorrect: self._state_incorrect.add(entity_id) _LOGGER.warning( - "Unable to use state. Only numerical states are supported," - " entity %s with value %s excluded from calculation", + "Unable to use state. Only entities with correct unit of measurement" + " is supported when having a device class," + " entity %s, value %s with device class %s" + " and unit of measurement %s excluded from calculation in %s", entity_id, state.state, + self.device_class, + state.attributes.get("unit_of_measurement"), + self.entity_id, ) - continue - valid_states.append(True) # Set group as unavailable if all members do not have numeric values self._attr_available = any(numeric_state for numeric_state in valid_states) @@ -350,7 +419,6 @@ class SensorGroup(GroupEntity, SensorEntity): return # Calculate values - self._calculate_entity_properties() self._extra_state_attribute, self._attr_native_value = self._state_calc( sensor_values ) @@ -360,13 +428,6 @@ class SensorGroup(GroupEntity, SensorEntity): """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property - def device_class(self) -> SensorDeviceClass | None: - """Return device class.""" - if self._attr_device_class is not None: - return self._attr_device_class - return self.calc_device_class - @property def icon(self) -> str | None: """Return the icon. @@ -377,59 +438,187 @@ class SensorGroup(GroupEntity, SensorEntity): return "mdi:calculator" return None - @property - def state_class(self) -> SensorStateClass | str | None: - """Return state class.""" - if self._attr_state_class is not None: - return self._attr_state_class - return self.calc_state_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return native unit of measurement.""" - if self._attr_native_unit_of_measurement is not None: - return self._attr_native_unit_of_measurement - return self.calc_unit_of_measurement - - def _calculate_entity_properties(self) -> None: - """Calculate device_class, state_class and unit of measurement.""" - device_classes = [] - state_classes = [] - unit_of_measurements = [] - - if ( - self._attr_device_class - and self._attr_state_class - and self._attr_native_unit_of_measurement - ): - return + def _calculate_state_class( + self, state_class: SensorStateClass | None + ) -> SensorStateClass | None: + """Calculate state class. + If user has configured a state class we will use that. + If a state class is not set then test if same state class + on source entities and use that. + Otherwise return no state class. + """ + if state_class: + return state_class + state_classes: list[SensorStateClass] = [] for entity_id in self._entity_ids: - if (state := self.hass.states.get(entity_id)) is not None: - device_classes.append(state.attributes.get("device_class")) - state_classes.append(state.attributes.get("state_class")) - unit_of_measurements.append(state.attributes.get("unit_of_measurement")) + try: + _state_class = get_capability(self.hass, entity_id, "state_class") + except HomeAssistantError: + return None + if not _state_class: + return None + state_classes.append(_state_class) - self.calc_device_class = None - self.calc_state_class = None - self.calc_unit_of_measurement = None + if all(x == state_classes[0] for x in state_classes): + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_state_classes_not_matching" + ) + return state_classes[0] + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_state_classes_not_matching", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="state_classes_not_matching", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "state_classes:": ", ".join(state_classes), + }, + ) + return None - # Calculate properties and save if all same + def _calculate_device_class( + self, device_class: SensorDeviceClass | None + ) -> SensorDeviceClass | None: + """Calculate device class. + + If user has configured a device class we will use that. + If a device class is not set then test if same device class + on source entities and use that. + Otherwise return no device class. + """ + if device_class: + return device_class + device_classes: list[SensorDeviceClass] = [] + for entity_id in self._entity_ids: + try: + _device_class = get_device_class(self.hass, entity_id) + except HomeAssistantError: + return None + if not _device_class: + return None + device_classes.append(SensorDeviceClass(_device_class)) + + if all(x == device_classes[0] for x in device_classes): + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_device_classes_not_matching" + ) + return device_classes[0] + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_device_classes_not_matching", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="device_classes_not_matching", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "device_classes:": ", ".join(device_classes), + }, + ) + return None + + def _calculate_unit_of_measurement( + self, unit_of_measurement: str | None + ) -> str | None: + """Calculate the unit of measurement. + + If user has configured a unit of measurement we will use that. + If a device class is set then test if unit of measurements are compatible. + If no device class or uom's not compatible we will use no unit of measurement. + """ + if unit_of_measurement: + return unit_of_measurement + + unit_of_measurements: list[str] = [] + for entity_id in self._entity_ids: + try: + _unit_of_measurement = get_unit_of_measurement(self.hass, entity_id) + except HomeAssistantError: + return None + if not _unit_of_measurement: + return None + unit_of_measurements.append(_unit_of_measurement) + + # Ensure only valid unit of measurements for the specific device class can be used if ( - not self._attr_device_class - and device_classes - and all(x == device_classes[0] for x in device_classes) + # Test if uom's in device class is convertible + (device_class := self.device_class) in UNIT_CONVERTERS + and all( + uom in UNIT_CONVERTERS[device_class].VALID_UNITS + for uom in unit_of_measurements + ) + ) or ( + # Test if uom's in device class is not convertible + device_class + and device_class not in UNIT_CONVERTERS + and device_class in DEVICE_CLASS_UNITS + and all( + uom in DEVICE_CLASS_UNITS[device_class] for uom in unit_of_measurements + ) ): - self.calc_device_class = device_classes[0] + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class" + ) + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class" + ) + return unit_of_measurements[0] + + if device_class: + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_uoms_not_matching_device_class", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="uoms_not_matching_device_class", + translation_placeholders={ + "entity_id": self.entity_id, + "device_class": device_class, + "source_entities": ", ".join(self._entity_ids), + "uoms": ", ".join(unit_of_measurements), + }, + ) + else: + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_uoms_not_matching_no_device_class", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="uoms_not_matching_no_device_class", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "uoms": ", ".join(unit_of_measurements), + }, + ) + return None + + def _get_valid_units(self) -> set[str | None]: + """Return valid units. + + If device class is set and compatible unit of measurements. + """ if ( - not self._attr_state_class - and state_classes - and all(x == state_classes[0] for x in state_classes) - ): - self.calc_state_class = state_classes[0] + device_class := self.device_class + ) in UNIT_CONVERTERS and self.native_unit_of_measurement: + self._can_convert = True + return UNIT_CONVERTERS[device_class].VALID_UNITS if ( - not self._attr_unit_of_measurement - and unit_of_measurements - and all(x == unit_of_measurements[0] for x in unit_of_measurements) + device_class + and (device_class) in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement ): - self.calc_unit_of_measurement = unit_of_measurements[0] + valid_uoms: set = DEVICE_CLASS_UNITS[device_class] + return valid_uoms + return set() diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index c5cebbc4707..25ae20da995 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -249,5 +249,23 @@ } } } + }, + "issues": { + "uoms_not_matching_device_class": { + "title": "Unit of measurements are not correct", + "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue." + }, + "uoms_not_matching_no_device_class": { + "title": "Unit of measurements is not correct", + "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible using no device class of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue." + }, + "device_classes_not_matching": { + "title": "Device classes is not correct", + "description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue." + }, + "state_classes_not_matching": { + "title": "State classes is not correct", + "description": "Device classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." + } } } diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 64bc9a99636..3f68d7125aa 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -87,7 +87,7 @@ async def async_setup_entry( @callback def async_create_preview_switch( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> SwitchGroup: """Create a preview sensor.""" return SwitchGroup( diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4e548ef2c2a..e4e7c638fa3 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -8,13 +8,16 @@ DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" SERVER_URLS = [ - "https://server-api.growatt.com/", - "https://server-us.growatt.com/", - "http://server.smten.com/", + "https://openapi.growatt.com/", # Other regional server + "https://openapi-cn.growatt.com/", # Chinese server + "https://openapi-us.growatt.com/", # North American server + "http://server.smten.com/", # smten server ] DEPRECATED_URLS = [ "https://server.growatt.com/", + "https://server-api.growatt.com/", + "https://server-us.growatt.com/", ] DEFAULT_URL = SERVER_URLS[0] diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 4a394692dd8..8a3ac265618 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, Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, cast +from typing import Any from aioguardian import Client from aioguardian.errors import GuardianError @@ -40,7 +40,7 @@ from .const import ( LOGGER, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) -from .util import GuardianDataUpdateCoordinator +from .coordinator import GuardianDataUpdateCoordinator DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" @@ -76,7 +76,13 @@ SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( }, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SENSOR, + Platform.SWITCH, + Platform.VALVE, +] @dataclass @@ -302,9 +308,7 @@ class PairedSensorManager: entry=self._entry, client=self._client, api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}", - api_coro=lambda: cast( - Awaitable, self._client.sensor.paired_sensor_status(uid) - ), + api_coro=lambda: self._client.sensor.paired_sensor_status(uid), api_lock=self._api_lock, valve_controller_uid=self._entry.data[CONF_UID], ) @@ -365,27 +369,8 @@ class GuardianEntity(CoordinatorEntity[GuardianDataUpdateCoordinator]): """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.""" @@ -410,20 +395,13 @@ class PairedSensorEntity(GuardianEntity): self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" -@dataclass(frozen=True) -class ValveControllerEntityDescriptionMixin: - """Define an entity description mixin for valve controller entities.""" +@dataclass(frozen=True, kw_only=True) +class ValveControllerEntityDescription(EntityDescription): + """Describe a Guardian valve controller entity.""" api_category: str -@dataclass(frozen=True) -class ValveControllerEntityDescription( - EntityDescription, ValveControllerEntityDescriptionMixin -): - """Describe a Guardian valve controller entity.""" - - class ValveControllerEntity(GuardianEntity): """Define a Guardian valve controller entity.""" diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 179158ab512..c7094cf624c 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, @@ -27,9 +29,9 @@ from .const import ( DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +from .coordinator import GuardianDataUpdateCoordinator from .util import ( EntityDomainReplacementStrategy, - GuardianDataUpdateCoordinator, async_finish_entity_domain_replacements, ) @@ -39,24 +41,35 @@ SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -@dataclass(frozen=True) +@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 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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 @@ async def async_setup_entry( 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 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): 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 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): 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 7a931f35019..cb9c6f0121c 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from aioguardian import Client -from aioguardian.errors import GuardianError from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,29 +14,22 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error -@dataclass(frozen=True) -class GuardianButtonEntityDescriptionMixin: - """Define an mixin for button entities.""" - - push_action: Callable[[Client], Awaitable] - - -@dataclass(frozen=True) +@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" @@ -103,14 +95,10 @@ class GuardianButton(ValveControllerEntity, ButtonEntity): self._client = data.client + @convert_exceptions_to_homeassistant_error async def async_press(self) -> None: """Send out a restart command.""" - try: - async with self._client: - await self.entity_description.push_action(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while pressing button "{self.entity_id}": {err}' - ) from err + async with self._client: + await self.entity_description.push_action(self._client) async_dispatcher_send(self.hass, self.coordinator.signal_reboot_requested) diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py new file mode 100644 index 00000000000..dda0a20be69 --- /dev/null +++ b/homeassistant/components/guardian/coordinator.py @@ -0,0 +1,79 @@ +"""Define Guardian-specific utilities.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any, cast + +from aioguardian import Client +from aioguardian.errors import GuardianError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) + +SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" + + +class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Define an extended DataUpdateCoordinator with some Guardian goodies.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + *, + entry: ConfigEntry, + client: Client, + api_name: str, + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], + api_lock: asyncio.Lock, + valve_controller_uid: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=f"{valve_controller_uid}_{api_name}", + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + + self._api_coro = api_coro + self._api_lock = api_lock + self._client = client + + self.config_entry = entry + self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( + self.config_entry.entry_id + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Execute a "locked" API request against the valve controller.""" + async with self._api_lock, self._client: + try: + resp = await self._api_coro() + except GuardianError as err: + raise UpdateFailed(err) from err + return cast(dict[str, Any], resp["data"]) + + async def async_initialize(self) -> None: + """Initialize the coordinator.""" + + @callback + def async_reboot_requested() -> None: + """Respond to a reboot request.""" + self.last_update_success = False + self.async_update_listeners() + + self.config_entry.async_on_unload( + async_dispatcher_connect( + self.hass, self.signal_reboot_requested, async_reboot_requested + ) + ) diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 68833234b15..64c70b07b83 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, @@ -12,6 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, + UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfTemperature, UnitOfTime, @@ -19,6 +22,7 @@ from homeassistant.const import ( 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, @@ -29,44 +33,88 @@ from . import ( from .const import ( API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, + API_VALVE_STATUS, CONF_UID, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +SENSOR_KIND_AVG_CURRENT = "average_current" SENSOR_KIND_BATTERY = "battery" +SENSOR_KIND_INST_CURRENT = "instantaneous_current" +SENSOR_KIND_INST_CURRENT_DDT = "instantaneous_current_ddt" SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_TRAVEL_COUNT = "travel_count" SENSOR_KIND_UPTIME = "uptime" -@dataclass(frozen=True) +@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 = ( + ValveControllerSensorDescription( + key=SENSOR_KIND_AVG_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["average_current"], + ), + ValveControllerSensorDescription( + key=SENSOR_KIND_INST_CURRENT, + translation_key="instantaneous_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["instantaneous_current"], + ), + ValveControllerSensorDescription( + key=SENSOR_KIND_INST_CURRENT_DDT, + translation_key="instantaneous_current_ddt", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["instantaneous_current_ddt"], + ), ValveControllerSensorDescription( key=SENSOR_KIND_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, 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 +123,16 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MINUTES, api_category=API_SYSTEM_DIAGNOSTICS, + value_fn=lambda data: data["uptime"], + ), + ValveControllerSensorDescription( + key=SENSOR_KIND_TRAVEL_COUNT, + translation_key="travel_count", + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="revolutions", + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["travel_count"], ), ) @@ -125,15 +183,12 @@ async def async_setup_entry( 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 +196,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/strings.json b/homeassistant/components/guardian/strings.json index 59630e87932..e8622fe9d03 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -33,6 +33,18 @@ } }, "sensor": { + "current": { + "name": "Current" + }, + "instantaneous_current": { + "name": "Instantaneous current" + }, + "instantaneous_current_ddt": { + "name": "Instantaneous current (DDT)" + }, + "travel_count": { + "name": "Travel count" + }, "uptime": { "name": "Uptime" } @@ -44,6 +56,11 @@ "valve_controller": { "name": "Valve controller" } + }, + "valve": { + "valve_controller": { + "name": "Valve controller" + } } }, "services": { @@ -52,7 +69,7 @@ "description": "Adds a new paired sensor to the valve controller.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller to add the sensor to." }, "uid": { @@ -66,7 +83,7 @@ "description": "Removes a paired sensor from the valve controller.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller to remove the sensor from." }, "uid": { @@ -80,7 +97,7 @@ "description": "Upgrades the device firmware.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller whose firmware should be upgraded." }, "url": { diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 98179c1922f..7db0fde8905 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,22 +1,22 @@ """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 from aioguardian import Client -from aioguardian.errors import GuardianError 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.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error +from .valve import GuardianValveState ATTR_AVG_CURRENT = "average_current" ATTR_CONNECTED_CLIENTS = "connected_clients" @@ -29,39 +29,51 @@ SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" -@dataclass(frozen=True) -class SwitchDescriptionMixin: - """Define an entity description mixin for Guardian switches.""" - - off_action: Callable[[Client], Awaitable] - on_action: Callable[[Client], Awaitable] - - -@dataclass(frozen=True) +@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.""" - await client.wifi.disable_ap() + async with client: + await client.wifi.disable_ap() async def _async_enable_ap(client: Client) -> None: """Enable the onboard AP.""" - await client.wifi.enable_ap() + async with client: + await client.wifi.enable_ap() async def _async_close_valve(client: Client) -> None: """Close the valve.""" - await client.valve.close() + async with client: + await client.valve.close() async def _async_open_valve(client: Client) -> None: """Open the valve.""" - await client.valve.open() + async with client: + await client.valve.open() + + +@callback +def is_open(data: dict[str, Any]) -> bool: + """Return if the valve is opening.""" + return data["state"] in ( + GuardianValveState.FINISH_OPENING, + GuardianValveState.OPEN, + GuardianValveState.OPENING, + GuardianValveState.START_OPENING, + ) VALVE_CONTROLLER_DESCRIPTIONS = ( @@ -70,17 +82,29 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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=is_open, + off_fn=_async_close_valve, + on_fn=_async_open_valve, ), ) @@ -100,13 +124,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,58 +137,24 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): 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) + + @convert_exceptions_to_homeassistant_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - if not self._attr_is_on: - return - - try: - async with self._client: - await self.entity_description.off_action(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while turning "{self.entity_id}" off: {err}' - ) from err - - self._attr_is_on = False - self.async_write_ha_state() + await self.entity_description.off_fn(self._client) + await self.coordinator.async_request_refresh() + @convert_exceptions_to_homeassistant_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - if self._attr_is_on: - return - - try: - async with self._client: - await self.entity_description.on_action(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while turning "{self.entity_id}" on: {err}' - ) from err - - self._attr_is_on = True - self.async_write_ha_state() + await self.entity_description.on_fn(self._client) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index ff41c6e4936..ffa57322551 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -1,27 +1,32 @@ """Define Guardian-specific utilities.""" from __future__ import annotations -import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta -from typing import Any, cast +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -from aioguardian import Client from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +if TYPE_CHECKING: + from . import GuardianEntity + + _GuardianEntityT = TypeVar("_GuardianEntityT", bound=GuardianEntity) + DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" +_P = ParamSpec("_P") + @dataclass class EntityDomainReplacementStrategy: @@ -29,9 +34,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,64 +57,26 @@ 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]): - """Define an extended DataUpdateCoordinator with some Guardian goodies.""" +@callback +def convert_exceptions_to_homeassistant_error( + func: Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate to handle exceptions from the Guardian API.""" - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - *, - entry: ConfigEntry, - client: Client, - api_name: str, - api_coro: Callable[..., Awaitable], - api_lock: asyncio.Lock, - valve_controller_uid: str, + @wraps(func) + async def wrapper( + entity: _GuardianEntityT, *args: _P.args, **kwargs: _P.kwargs ) -> None: - """Initialize.""" - super().__init__( - hass, - LOGGER, - name=f"{valve_controller_uid}_{api_name}", - update_interval=DEFAULT_UPDATE_INTERVAL, - ) + """Wrap the provided function.""" + try: + await func(entity, *args, **kwargs) + except GuardianError as err: + raise HomeAssistantError( + f"Error while calling {func.__name__}: {err}" + ) from err - self._api_coro = api_coro - self._api_lock = api_lock - self._client = client - - self.config_entry = entry - self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( - self.config_entry.entry_id - ) - - async def _async_update_data(self) -> dict[str, Any]: - """Execute a "locked" API request against the valve controller.""" - async with self._api_lock, self._client: - try: - resp = await self._api_coro() - except GuardianError as err: - raise UpdateFailed(err) from err - return cast(dict[str, Any], resp["data"]) - - async def async_initialize(self) -> None: - """Initialize the coordinator.""" - - @callback - def async_reboot_requested() -> None: - """Respond to a reboot request.""" - self.last_update_success = False - self.async_update_listeners() - - self.config_entry.async_on_unload( - async_dispatcher_connect( - self.hass, self.signal_reboot_requested, async_reboot_requested - ) - ) + return wrapper diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py new file mode 100644 index 00000000000..a2b6b5b6ab7 --- /dev/null +++ b/homeassistant/components/guardian/valve.py @@ -0,0 +1,171 @@ +"""Valves for the Elexa Guardian integration.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from aioguardian import Client + +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 . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription +from .const import API_VALVE_STATUS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error + +VALVE_KIND_VALVE = "valve" + + +class GuardianValveState(StrEnum): + """States of a valve.""" + + CLOSED = "closed" + CLOSING = "closing" + FINISH_CLOSING = "finish_closing" + FINISH_OPENING = "finish_opening" + OPEN = "open" + OPENING = "opening" + START_CLOSING = "start_closing" + START_OPENING = "start_opening" + + +@dataclass(frozen=True, kw_only=True) +class ValveControllerValveDescription( + ValveEntityDescription, ValveControllerEntityDescription +): + """Describe a Guardian valve controller valve.""" + + is_closed_fn: Callable[[dict[str, Any]], bool] + is_closing_fn: Callable[[dict[str, Any]], bool] + is_opening_fn: Callable[[dict[str, Any]], bool] + close_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + halt_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + open_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + + +async def async_close_valve(client: Client) -> None: + """Close the valve.""" + async with client: + await client.valve.close() + + +async def async_halt_valve(client: Client) -> None: + """Halt the valve.""" + async with client: + await client.valve.halt() + + +async def async_open_valve(client: Client) -> None: + """Open the valve.""" + async with client: + await client.valve.open() + + +@callback +def is_closing(data: dict[str, Any]) -> bool: + """Return if the valve is closing.""" + return data["state"] in ( + GuardianValveState.CLOSING, + GuardianValveState.FINISH_CLOSING, + GuardianValveState.START_CLOSING, + ) + + +@callback +def is_opening(data: dict[str, Any]) -> bool: + """Return if the valve is opening.""" + return data["state"] in ( + GuardianValveState.OPENING, + GuardianValveState.FINISH_OPENING, + GuardianValveState.START_OPENING, + ) + + +VALVE_CONTROLLER_DESCRIPTIONS = ( + ValveControllerValveDescription( + key=VALVE_KIND_VALVE, + translation_key="valve_controller", + device_class=ValveDeviceClass.WATER, + api_category=API_VALVE_STATUS, + is_closed_fn=lambda data: data["state"] == GuardianValveState.CLOSED, + is_closing_fn=is_closing, + is_opening_fn=is_opening, + close_coro_fn=async_close_valve, + halt_coro_fn=async_halt_valve, + open_coro_fn=async_open_valve, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Guardian switches based on a config entry.""" + data: GuardianData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ValveControllerValve(entry, data, description) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ) + + +class ValveControllerValve(ValveControllerEntity, ValveEntity): + """Define a switch related to a Guardian valve controller.""" + + _attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE | ValveEntityFeature.STOP + ) + entity_description: ValveControllerValveDescription + + def __init__( + self, + entry: ConfigEntry, + data: GuardianData, + description: ValveControllerValveDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, data.valve_controller_coordinators, description) + + self._client = data.client + + @property + def is_closing(self) -> bool: + """Return if the valve is closing or not.""" + return self.entity_description.is_closing_fn(self.coordinator.data) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self.entity_description.is_closed_fn(self.coordinator.data) + + @property + def is_opening(self) -> bool: + """Return if the valve is opening or not.""" + return self.entity_description.is_opening_fn(self.coordinator.data) + + @convert_exceptions_to_homeassistant_error + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.entity_description.close_coro_fn(self._client) + await self.coordinator.async_request_refresh() + + @convert_exceptions_to_homeassistant_error + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.entity_description.open_coro_fn(self._client) + await self.coordinator.async_request_refresh() + + @convert_exceptions_to_homeassistant_error + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.entity_description.halt_coro_fn(self._client) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index d861068629f..327dbad343b 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cancel_listener = entry.add_update_listener(_update_listener) - async def _async_on_stop(event): + async def _async_on_stop(event: Event) -> None: await data.shutdown() cancel_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_on_stop) @@ -56,11 +56,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _migrate_old_unique_ids( hass: HomeAssistant, entry_id: str, data: HarmonyData -): +) -> None: names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} @callback - def _async_migrator(entity_entry: er.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None: # Old format for switches was {remote_unique_id}-{activity_name} # New format is activity_{activity_id} parts = entity_entry.unique_id.split("-", 1) @@ -82,7 +82,9 @@ async def _migrate_old_unique_ids( @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: ConfigEntry +) -> None: options = dict(entry.options) modified = 0 for importable_option in (ATTR_ACTIVITY, ATTR_DELAY_SECS): diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index f74a19425ab..ad041e75f1a 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -35,7 +35,7 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(data): +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -60,9 +60,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the Harmony config flow.""" self.harmony_config: dict[str, Any] = {} - 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 = {} + errors: dict[str, str] = {} if user_input is not None: try: validated = await validate_input(user_input) @@ -116,9 +118,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.harmony_config[UNIQUE_ID] = unique_id return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Harmony.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: # Everything was validated in async_step_ssdp @@ -145,7 +149,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) - async def _async_create_entry_from_valid_input(self, validated, user_input): + async def _async_create_entry_from_valid_input( + self, validated: dict[str, Any], user_input: dict[str, Any] + ) -> FlowResult: """Single path to create the config entry from validated input.""" data = { @@ -159,8 +165,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=validated[CONF_NAME], data=data) -def _options_from_user_input(user_input): - options = {} +def _options_from_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + options: dict[str, Any] = {} if ATTR_ACTIVITY in user_input: options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY] if ATTR_DELAY_SECS in user_input: @@ -175,7 +181,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index a1b11189a04..44c0fde19c1 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -45,7 +45,7 @@ class HarmonyData(HarmonySubscriberMixin): ] @property - def activity_names(self): + def activity_names(self) -> list[str]: """Names of all the remotes activities.""" activity_infos = self.activities activities = [activity["label"] for activity in activity_infos] @@ -61,7 +61,7 @@ class HarmonyData(HarmonySubscriberMixin): return devices @property - def name(self): + def name(self) -> str: """Return the Harmony device's name.""" return self._name @@ -138,7 +138,7 @@ class HarmonyData(HarmonySubscriberMixin): f"{self._name}: Unable to connect to HUB at: {self._address}:8088" ) - async def shutdown(self): + async def shutdown(self) -> None: """Close connection on shutdown.""" _LOGGER.debug("%s: Closing Harmony Hub", self._name) try: @@ -146,7 +146,7 @@ class HarmonyData(HarmonySubscriberMixin): except aioexc.TimeOut: _LOGGER.warning("%s: Disconnect timed-out", self._name) - async def async_start_activity(self, activity: str): + async def async_start_activity(self, activity: str) -> None: """Start an activity from the Harmony device.""" if not activity: @@ -189,7 +189,7 @@ class HarmonyData(HarmonySubscriberMixin): _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) self.async_unlock_start_activity() - async def async_power_off(self): + async def async_power_off(self) -> None: """Start the PowerOff activity.""" _LOGGER.debug("%s: Turn Off", self.name) try: @@ -204,7 +204,7 @@ class HarmonyData(HarmonySubscriberMixin): num_repeats: int, delay_secs: float, hold_secs: float, - ): + ) -> None: """Send a list of commands to one device.""" device_id = None if device.isdigit(): @@ -259,7 +259,7 @@ class HarmonyData(HarmonySubscriberMixin): result.msg, ) - async def change_channel(self, channel: int): + async def change_channel(self, channel: int) -> None: """Change the channel using Harmony remote.""" _LOGGER.debug("%s: Changing channel to %s", self.name, channel) try: diff --git a/homeassistant/components/harmony/entity.py b/homeassistant/components/harmony/entity.py index 24c72a771e7..b1b1599a16c 100644 --- a/homeassistant/components/harmony/entity.py +++ b/homeassistant/components/harmony/entity.py @@ -1,4 +1,8 @@ """Base class Harmony entities.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime import logging from homeassistant.helpers.entity import Entity @@ -17,7 +21,7 @@ class HarmonyEntity(Entity): def __init__(self, data: HarmonyData) -> None: """Initialize the Harmony base entity.""" super().__init__() - self._unsub_mark_disconnected = None + self._unsub_mark_disconnected: Callable[[], None] | None = None self._name = data.name self._data = data self._attr_should_poll = False @@ -27,14 +31,14 @@ class HarmonyEntity(Entity): """Return True if we're connected to the Hub, otherwise False.""" return self._data.available - async def async_got_connected(self, _=None): + async def async_got_connected(self, _: str | None = None) -> None: """Notification that we're connected to the HUB.""" _LOGGER.debug("%s: connected to the HUB", self._name) self.async_write_ha_state() self._clear_disconnection_delay() - async def async_got_disconnected(self, _=None): + async def async_got_disconnected(self, _: str | None = None) -> None: """Notification that we're disconnected from the HUB.""" _LOGGER.debug("%s: disconnected from the HUB", self._name) # We're going to wait for 10 seconds before announcing we're @@ -43,12 +47,12 @@ class HarmonyEntity(Entity): self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable ) - def _clear_disconnection_delay(self): + def _clear_disconnection_delay(self) -> None: if self._unsub_mark_disconnected: self._unsub_mark_disconnected() self._unsub_mark_disconnected = None - def _mark_disconnected_if_unavailable(self, _): + def _mark_disconnected_if_unavailable(self, _: datetime) -> None: self._unsub_mark_disconnected = None if not self.available: # Still disconnected. Let the state engine know. diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index c1e85c86787..863c3fe5c56 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -1,4 +1,6 @@ """Support for Harmony Hub devices.""" +from __future__ import annotations + from collections.abc import Iterable import json import logging @@ -36,6 +38,7 @@ from .const import ( SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, ) +from .data import HarmonyData from .entity import HarmonyEntity from .subscriber import HarmonyCallback @@ -56,12 +59,12 @@ async def async_setup_entry( ) -> None: """Set up the Harmony config entry.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] _LOGGER.debug("HarmonyData : %s", data) - default_activity = entry.options.get(ATTR_ACTIVITY) - delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + default_activity: str | None = entry.options.get(ATTR_ACTIVITY) + delay_secs: float = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") device = HarmonyRemote(data, default_activity, delay_secs, harmony_conf_file) @@ -84,10 +87,12 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): _attr_supported_features = RemoteEntityFeature.ACTIVITY - def __init__(self, data, activity, delay_secs, out_path): + def __init__( + self, data: HarmonyData, activity: str | None, delay_secs: float, out_path: str + ) -> None: """Initialize HarmonyRemote class.""" super().__init__(data=data) - self._state = None + self._state: bool | None = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None @@ -99,7 +104,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): self._attr_device_info = self._data.device_info(DOMAIN) self._attr_name = data.name - async def _async_update_options(self, data): + async def _async_update_options(self, data: dict[str, Any]) -> None: """Change options when the options flow does.""" if ATTR_DELAY_SECS in data: self.delay_secs = data[ATTR_DELAY_SECS] @@ -170,7 +175,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): return self._data.activity_names @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Add platform specific attributes.""" return { ATTR_ACTIVITY_STARTING: self._activity_starting, @@ -179,7 +184,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): } @property - def is_on(self): + def is_on(self) -> bool: """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, "PowerOff"] @@ -201,7 +206,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): self._state = bool(activity_id != -1) self.async_write_ha_state() - async def async_new_config(self, _=None): + async def async_new_config(self, _: dict | None = None) -> None: """Call for updating the current activity.""" _LOGGER.debug("%s: configuration has been updated", self.name) self.async_new_activity(self._data.current_activity) @@ -242,16 +247,16 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): command, device, num_repeats, delay_secs, hold_secs ) - async def change_channel(self, channel): + async def change_channel(self, channel: int) -> None: """Change the channel using Harmony remote.""" await self._data.change_channel(channel) - async def sync(self): + async def sync(self) -> None: """Sync the Harmony device with the web service.""" if await self._data.sync(): await self.hass.async_add_executor_job(self.write_config_file) - def write_config_file(self): + def write_config_file(self) -> None: """Write Harmony configuration file. This is a handy way for users to figure out the available commands for automations. diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py index 0ed3f0ca275..e98a15c788f 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -23,7 +23,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activities select.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] _LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME]) async_add_entities( [HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)] @@ -85,5 +85,5 @@ class HarmonyActivitySelect(HarmonyEntity, SelectEntity): ) @callback - def _async_activity_update(self, activity_info: tuple): + def _async_activity_update(self, activity_info: tuple) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py index 4804253151f..8a47e437e17 100644 --- a/homeassistant/components/harmony/subscriber.py +++ b/homeassistant/components/harmony/subscriber.py @@ -34,7 +34,7 @@ class HarmonySubscriberMixin: self._subscriptions: list[HarmonyCallback] = [] self._activity_lock = asyncio.Lock() - async def async_lock_start_activity(self): + async def async_lock_start_activity(self) -> None: """Acquire the lock.""" await self._activity_lock.acquire() @@ -59,17 +59,17 @@ class HarmonySubscriberMixin: """Remove a callback subscriber.""" self._subscriptions.remove(update_callback) - def _config_updated(self, _=None) -> None: + def _config_updated(self, _: dict | None = None) -> None: _LOGGER.debug("config_updated") self._call_callbacks("config_updated") - def _connected(self, _=None) -> None: + def _connected(self, _: str | None = None) -> None: _LOGGER.debug("connected") self.async_unlock_start_activity() self._available = True self._call_callbacks("connected") - def _disconnected(self, _=None) -> None: + def _disconnected(self, _: str | None = None) -> None: _LOGGER.debug("disconnected") self.async_unlock_start_activity() self._available = False diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 2d072f11f2c..c5bba39eb95 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,7 +23,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities switches = [] @@ -49,7 +49,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self._attr_device_info = self._data.device_info(DOMAIN) @property - def is_on(self): + def is_on(self) -> bool: """Return if the current activity is the one for this switch.""" _, activity_name = self._data.current_activity return activity_name == self._activity_name @@ -111,5 +111,5 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): ) @callback - def _async_activity_update(self, activity_info: tuple): + def _async_activity_update(self, activity_info: tuple) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py index 3f126f22f3c..0bfee32b414 100644 --- a/homeassistant/components/harmony/util.py +++ b/homeassistant/components/harmony/util.py @@ -25,7 +25,7 @@ def find_best_name_for_remote(data: dict, harmony: HarmonyAPI): return data[CONF_NAME] -async def get_harmony_client_if_available(ip_address: str): +async def get_harmony_client_if_available(ip_address: str) -> HarmonyAPI | None: """Connect to a harmony hub and fetch info.""" harmony = HarmonyAPI(ip_address=ip_address) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3dd9b11ae64..1472843e14d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, + Event, HassJob, HomeAssistant, ServiceCall, @@ -332,7 +333,7 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: @callback @bind_hass -def get_addons_stats(hass): +def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]: """Return Addons stats. Async friendly. @@ -342,7 +343,7 @@ def get_addons_stats(hass): @callback @bind_hass -def get_core_stats(hass): +def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: """Return core stats. Async friendly. @@ -352,7 +353,7 @@ def get_core_stats(hass): @callback @bind_hass -def get_supervisor_stats(hass): +def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: """Return supervisor stats. Async friendly. @@ -362,7 +363,7 @@ def get_supervisor_stats(hass): @callback @bind_hass -def get_addons_changelogs(hass): +def get_addons_changelogs(hass: HomeAssistant): """Return Addons changelogs. Async friendly. @@ -488,7 +489,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: last_timezone = None - async def push_config(_): + async def push_config(_: Event | None) -> None: """Push core config to Hass.io.""" nonlocal last_timezone @@ -745,7 +746,7 @@ def async_remove_addons_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioDataUpdateCoordinator(DataUpdateCoordinator): +class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to retrieve Hass.io status.""" def __init__( @@ -986,7 +987,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): enabled_updates[key].add(entity_id) @callback - def _remove(): + def _remove() -> None: for key in types: enabled_updates[key].remove(entity_id) @@ -1000,12 +1001,18 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): raise_on_entry_error: bool = False, ) -> None: """Refresh data.""" - if not scheduled: + if not scheduled and not raise_on_auth_failed: # Force refreshing updates for non-scheduled updates + # If `raise_on_auth_failed` is set, it means this is + # the first refresh and we do not want to delay + # startup or cause a timeout so we only refresh the + # updates if this is not a scheduled refresh and + # we are not doing the first refresh. try: await self.hassio.refresh_updates() except HassioAPIError as err: _LOGGER.warning("Error on Supervisor API: %s", err) + await super()._async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index b2cf0040be0..8ebf4bf5cca 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus import logging +from typing import Any from aiohttp import web @@ -11,12 +12,12 @@ from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_PANELS, ATTR_TITLE -from .handler import HassioAPIError +from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) -async def async_setup_addon_panel(hass: HomeAssistant, hassio): +async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None: """Add-on Ingress Panel setup.""" hassio_addon_panel = HassIOAddonPanel(hass, hassio) hass.http.register_view(hassio_addon_panel) @@ -26,7 +27,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): return # Register available panels - jobs = [] + jobs: list[asyncio.Task[None]] = [] for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue @@ -46,12 +47,12 @@ class HassIOAddonPanel(HomeAssistantView): name = "api:hassio_push:panel" url = "/api/hassio_push/panel/{addon}" - def __init__(self, hass, hassio): + def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None: """Initialize WebView.""" self.hass = hass self.hassio = hassio - async def post(self, request, addon): + async def post(self, request: web.Request, addon: str) -> web.Response: """Handle new add-on panel requests.""" panels = await self.get_panels() @@ -65,12 +66,12 @@ class HassIOAddonPanel(HomeAssistantView): await _register_panel(self.hass, addon, data) return web.Response() - async def delete(self, request, addon): + async def delete(self, request: web.Request, addon: str) -> web.Response: """Handle remove add-on panel requests.""" frontend.async_remove_panel(self.hass, addon) return web.Response() - async def get_panels(self): + async def get_panels(self) -> dict: """Return panels add-on info data.""" try: data = await self.hassio.get_ingress_panels() @@ -80,7 +81,9 @@ class HassIOAddonPanel(HomeAssistantView): return {} -async def _register_panel(hass, addon, data): +async def _register_panel( + hass: HomeAssistant, addon: str, data: dict[str, Any] +) -> None: """Init coroutine to register the panel.""" await panel_custom.async_register_panel( hass, diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index afe944d03bc..1e20b3da8e5 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_auth_view(hass: HomeAssistant, user: User): +def async_setup_auth_view(hass: HomeAssistant, user: User) -> None: """Auth setup.""" hassio_auth = HassIOAuth(hass, user) hassio_password_reset = HassIOPasswordReset(hass, user) @@ -38,7 +38,7 @@ class HassIOBaseAuth(HomeAssistantView): self.hass = hass self.user = user - def _check_access(self, request: web.Request): + def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" # Check caller IP hassio_ip = os.environ["SUPERVISOR"].split(":")[0] @@ -71,7 +71,7 @@ class HassIOAuth(HassIOBaseAuth): extra=vol.ALLOW_EXTRA, ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle auth requests.""" self._check_access(request) provider = auth_ha.async_get_provider(request.app["hass"]) @@ -101,7 +101,7 @@ class HassIOPasswordReset(HassIOBaseAuth): extra=vol.ALLOW_EXTRA, ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle password reset requests.""" self._check_access(request) provider = auth_ha.async_get_provider(request.app["hass"]) diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index 6ebd42e7610..ef09f07b4de 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -1,7 +1,11 @@ """Config flow for Home Assistant Supervisor integration.""" +from __future__ import annotations + import logging +from typing import Any from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from . import DOMAIN @@ -13,7 +17,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_system(self, user_input=None): + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" # We only need one Hass.io config entry await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 2a5ce2485d1..1810e3ed2c5 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -12,7 +12,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow @@ -33,13 +33,13 @@ class HassioServiceInfo(BaseServiceInfo): @callback -def async_setup_discovery_view(hass: HomeAssistant, hassio): +def async_setup_discovery_view(hass: HomeAssistant, hassio: HassIO) -> None: """Discovery setup.""" hassio_discovery = HassIODiscovery(hass, hassio) hass.http.register_view(hassio_discovery) # Handle exists discovery messages - async def _async_discovery_start_handler(event): + async def _async_discovery_start_handler(event: Event) -> None: """Process all exists discovery on startup.""" try: data = await hassio.retrieve_discovery_messages() @@ -70,7 +70,7 @@ class HassIODiscovery(HomeAssistantView): self.hass = hass self.hassio = hassio - async def post(self, request, uuid): + async def post(self, request: web.Request, uuid: str) -> web.Response: """Handle new discovery requests.""" # Fetch discovery data and prevent injections try: @@ -82,9 +82,9 @@ class HassIODiscovery(HomeAssistantView): await self.async_process_new(data) return web.Response() - async def delete(self, request, uuid): + async def delete(self, request: web.Request, uuid: str) -> web.Response: """Handle remove discovery requests.""" - data = await request.json() + data: dict[str, Any] = await request.json() await self.async_process_del(data) return web.Response() @@ -114,7 +114,7 @@ class HassIODiscovery(HomeAssistantView): data=HassioServiceInfo(config=config_data, name=name, slug=slug, uuid=uuid), ) - async def async_process_del(self, data): + async def async_process_del(self, data: dict[str, Any]) -> None: """Process remove discovery entry.""" service = data[ATTR_SERVICE] uuid = data[ATTR_UUID] diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index fe9e1ba1d2e..ddaebcbf2a7 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine from http import HTTPStatus import logging import os @@ -10,6 +11,7 @@ from typing import Any import aiohttp from yarl import URL +from homeassistant.auth.models import RefreshToken from homeassistant.components.http import ( CONF_SERVER_HOST, CONF_SERVER_PORT, @@ -62,7 +64,7 @@ async def async_get_addon_info(hass: HomeAssistant, slug: str) -> dict: The add-on must be installed. The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] return await hassio.get_addon_info(slug) @@ -83,7 +85,7 @@ async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> di The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] return await hassio.update_diagnostics(diagnostics) @@ -94,7 +96,7 @@ async def async_install_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/install" return await hassio.send_command(command, timeout=None) @@ -106,7 +108,7 @@ async def async_uninstall_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/uninstall" return await hassio.send_command(command, timeout=60) @@ -122,7 +124,7 @@ async def async_update_addon( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/update" return await hassio.send_command( command, @@ -138,7 +140,7 @@ async def async_start_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/start" return await hassio.send_command(command, timeout=60) @@ -150,7 +152,7 @@ async def async_restart_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/restart" return await hassio.send_command(command, timeout=None) @@ -162,7 +164,7 @@ async def async_stop_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/stop" return await hassio.send_command(command, timeout=60) @@ -176,7 +178,7 @@ async def async_set_addon_options( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/options" return await hassio.send_command(command, payload=options) @@ -184,7 +186,7 @@ async def async_set_addon_options( @bind_hass async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict | None: """Return discovery data for an add-on.""" - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] data = await hassio.retrieve_discovery_messages() discovered_addons = data[ATTR_DISCOVERY] return next((addon for addon in discovered_addons if addon["addon"] == slug), None) @@ -199,7 +201,7 @@ async def async_create_backup( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] backup_type = "partial" if partial else "full" command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -212,7 +214,7 @@ async def async_update_os(hass: HomeAssistant, version: str | None = None) -> di The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/os/update" return await hassio.send_command( command, @@ -228,7 +230,7 @@ async def async_update_supervisor(hass: HomeAssistant) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/supervisor/update" return await hassio.send_command(command, timeout=None) @@ -242,7 +244,7 @@ async def async_update_core( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/core/update" return await hassio.send_command( command, @@ -258,7 +260,7 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/resolution/suggestion/{suggestion_uuid}" return await hassio.send_command(command, timeout=None) @@ -328,9 +330,10 @@ class HassIO: self.loop = loop self.websession = websession self._ip = ip + self._base_url = URL(f"http://{ip}") @_api_bool - def is_connected(self): + def is_connected(self) -> Coroutine: """Return true if it connected to Hass.io supervisor. This method returns a coroutine. @@ -338,7 +341,7 @@ class HassIO: return self.send_command("/supervisor/ping", method="get", timeout=15) @api_data - def get_info(self): + def get_info(self) -> Coroutine: """Return generic Supervisor information. This method returns a coroutine. @@ -346,7 +349,7 @@ class HassIO: return self.send_command("/info", method="get") @api_data - def get_host_info(self): + def get_host_info(self) -> Coroutine: """Return data for Host. This method returns a coroutine. @@ -354,7 +357,7 @@ class HassIO: return self.send_command("/host/info", method="get") @api_data - def get_os_info(self): + def get_os_info(self) -> Coroutine: """Return data for the OS. This method returns a coroutine. @@ -362,7 +365,7 @@ class HassIO: return self.send_command("/os/info", method="get") @api_data - def get_core_info(self): + def get_core_info(self) -> Coroutine: """Return data for Home Asssistant Core. This method returns a coroutine. @@ -370,7 +373,7 @@ class HassIO: return self.send_command("/core/info", method="get") @api_data - def get_supervisor_info(self): + def get_supervisor_info(self) -> Coroutine: """Return data for the Supervisor. This method returns a coroutine. @@ -378,7 +381,7 @@ class HassIO: return self.send_command("/supervisor/info", method="get") @api_data - def get_addon_info(self, addon): + def get_addon_info(self, addon: str) -> Coroutine: """Return data for a Add-on. This method returns a coroutine. @@ -386,7 +389,7 @@ class HassIO: return self.send_command(f"/addons/{addon}/info", method="get") @api_data - def get_core_stats(self): + def get_core_stats(self) -> Coroutine: """Return stats for the core. This method returns a coroutine. @@ -394,7 +397,7 @@ class HassIO: return self.send_command("/core/stats", method="get") @api_data - def get_addon_stats(self, addon): + def get_addon_stats(self, addon: str) -> Coroutine: """Return stats for an Add-on. This method returns a coroutine. @@ -402,14 +405,14 @@ class HassIO: return self.send_command(f"/addons/{addon}/stats", method="get") @api_data - def get_supervisor_stats(self): + def get_supervisor_stats(self) -> Coroutine: """Return stats for the supervisor. This method returns a coroutine. """ return self.send_command("/supervisor/stats", method="get") - def get_addon_changelog(self, addon): + def get_addon_changelog(self, addon: str) -> Coroutine: """Return changelog for an Add-on. This method returns a coroutine. @@ -419,7 +422,7 @@ class HassIO: ) @api_data - def get_store(self): + def get_store(self) -> Coroutine: """Return data from the store. This method returns a coroutine. @@ -427,7 +430,7 @@ class HassIO: return self.send_command("/store", method="get") @api_data - def get_ingress_panels(self): + def get_ingress_panels(self) -> Coroutine: """Return data for Add-on ingress panels. This method returns a coroutine. @@ -435,7 +438,7 @@ class HassIO: return self.send_command("/ingress/panels", method="get") @_api_bool - def restart_homeassistant(self): + def restart_homeassistant(self) -> Coroutine: """Restart Home-Assistant container. This method returns a coroutine. @@ -443,7 +446,7 @@ class HassIO: return self.send_command("/homeassistant/restart") @_api_bool - def stop_homeassistant(self): + def stop_homeassistant(self) -> Coroutine: """Stop Home-Assistant container. This method returns a coroutine. @@ -451,15 +454,15 @@ class HassIO: return self.send_command("/homeassistant/stop") @_api_bool - def refresh_updates(self): + def refresh_updates(self) -> Coroutine: """Refresh available updates. This method returns a coroutine. """ - return self.send_command("/refresh_updates", timeout=None) + return self.send_command("/refresh_updates", timeout=300) @api_data - def retrieve_discovery_messages(self): + def retrieve_discovery_messages(self) -> Coroutine: """Return all discovery data from Hass.io API. This method returns a coroutine. @@ -467,7 +470,7 @@ class HassIO: return self.send_command("/discovery", method="get", timeout=60) @api_data - def get_discovery_message(self, uuid): + def get_discovery_message(self, uuid: str) -> Coroutine: """Return a single discovery data message. This method returns a coroutine. @@ -475,7 +478,7 @@ class HassIO: return self.send_command(f"/discovery/{uuid}", method="get") @api_data - def get_resolution_info(self): + def get_resolution_info(self) -> Coroutine: """Return data for Supervisor resolution center. This method returns a coroutine. @@ -483,7 +486,9 @@ class HassIO: return self.send_command("/resolution/info", method="get") @api_data - def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]: + def get_suggestions_for_issue( + self, issue_id: str + ) -> Coroutine[Any, Any, dict[str, Any]]: """Return suggestions for issue from Supervisor resolution center. This method returns a coroutine. @@ -493,7 +498,9 @@ class HassIO: ) @_api_bool - async def update_hass_api(self, http_config, refresh_token): + async def update_hass_api( + self, http_config: dict[str, Any], refresh_token: RefreshToken + ): """Update Home Assistant API data on Hass.io.""" port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT options = { @@ -513,7 +520,7 @@ class HassIO: return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, timezone): + def update_hass_timezone(self, timezone: str) -> Coroutine: """Update Home-Assistant timezone data on Hass.io. This method returns a coroutine. @@ -521,7 +528,7 @@ class HassIO: return self.send_command("/supervisor/options", payload={"timezone": timezone}) @_api_bool - def update_diagnostics(self, diagnostics: bool): + def update_diagnostics(self, diagnostics: bool) -> Coroutine: """Update Supervisor diagnostics setting. This method returns a coroutine. @@ -531,7 +538,7 @@ class HassIO: ) @_api_bool - def apply_suggestion(self, suggestion_uuid: str): + def apply_suggestion(self, suggestion_uuid: str) -> Coroutine: """Apply a suggestion from supervisor's resolution center. This method returns a coroutine. @@ -540,27 +547,33 @@ class HassIO: async def send_command( self, - command, - method="post", - payload=None, - timeout=10, - return_text=False, + command: str, + method: str = "post", + payload: Any | None = None, + timeout: int | None = 10, + return_text: bool = False, *, - source="core.handler", - ): + source: str = "core.handler", + ) -> Any: """Send API command to Hass.io. This method is a coroutine. """ url = f"http://{self._ip}{command}" - if url != str(URL(url)): + joined_url = self._base_url.join(URL(command)) + # This check is to make sure the normalized URL string + # is the same as the URL string that was passed in. If + # they are different, then the passed in command URL + # contained characters that were removed by the normalization + # such as ../../../../etc/passwd + if url != str(joined_url): _LOGGER.error("Invalid request %s", command) raise HassioAPIError() try: request = await self.websession.request( method, - f"http://{self._ip}{command}", + joined_url, json=payload, headers={ aiohttp.hdrs.AUTHORIZATION: ( @@ -578,7 +591,7 @@ class HassIO: if return_text: return await request.text(encoding="utf-8") - return await request.json() + return await request.json(encoding="utf-8") except asyncio.TimeoutError: _LOGGER.error("Timeout on %s request", command) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 0c0fe55b686..4f3933d0f5c 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -9,7 +9,7 @@ import logging from urllib.parse import quote import aiohttp -from aiohttp import ClientTimeout, hdrs, web +from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -46,7 +46,7 @@ MAX_SIMPLE_RESPONSE_SIZE = 4194000 @callback -def async_setup_ingress_view(hass: HomeAssistant, host: str): +def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: """Auth setup.""" websession = async_get_clientsession(hass) @@ -281,7 +281,10 @@ def _is_websocket(request: web.Request) -> bool: ) -async def _websocket_forward(ws_from, ws_to): +async def _websocket_forward( + ws_from: web.WebSocketResponse | ClientWebSocketResponse, + ws_to: web.WebSocketResponse | ClientWebSocketResponse, +) -> None: """Handle websocket message directly.""" try: async for msg in ws_from: @@ -294,7 +297,7 @@ async def _websocket_forward(ws_from, ws_to): elif msg.type == aiohttp.WSMsgType.PONG: await ws_to.pong() elif ws_to.closed: - await ws_to.close(code=ws_to.close_code, message=msg.extra) + await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type] except RuntimeError: _LOGGER.debug("Ingress Websocket runtime error") except ConnectionResetError: diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index 74437186ff2..d89224a2476 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from typing import Any from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback @@ -20,7 +21,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass: HomeAssistant): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" info = get_info(hass) or {} host_info = get_host_info(hass) or {} diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 8f44f7f2843..ae04aa0fff5 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -54,7 +54,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) @callback -def async_load_websocket_api(hass: HomeAssistant): +def async_load_websocket_api(hass: HomeAssistant) -> None: """Set up the websocket API.""" websocket_api.async_register_command(hass, websocket_supervisor_event) websocket_api.async_register_command(hass, websocket_supervisor_api) @@ -66,11 +66,11 @@ def async_load_websocket_api(hass: HomeAssistant): @websocket_api.async_response async def websocket_subscribe( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Subscribe to supervisor events.""" @callback - def forward_messages(data): + def forward_messages(data: dict[str, str]) -> None: """Forward events to websocket.""" connection.send_message(websocket_api.event_message(msg[WS_ID], data)) @@ -89,7 +89,7 @@ async def websocket_subscribe( @websocket_api.async_response async def websocket_supervisor_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Publish events from the Supervisor.""" connection.send_result(msg[WS_ID]) async_dispatcher_send(hass, EVENT_SUPERVISOR_EVENT, msg[ATTR_DATA]) @@ -107,7 +107,7 @@ async def websocket_supervisor_event( @websocket_api.async_response async def websocket_supervisor_api( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Websocket handler to call Supervisor API.""" if not connection.user.is_admin and not WS_NO_ADMIN_ENDPOINTS.match( msg[ATTR_ENDPOINT] diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 24a0c88b45a..566a4696a73 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -76,7 +76,12 @@ class HeatmiserV3Thermostat(ClimateEntity): """Representation of a HeatmiserV3 thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, therm, device, uh1): """Initialize the thermostat.""" diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index ca5ec694eab..0e3fa9981c1 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -144,6 +144,8 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes = FAN_MODES _attr_swing_modes = SWING_MODES @@ -152,6 +154,7 @@ class ClimateAehW4a1(ClimateEntity): _attr_target_temperature_step = 1 _previous_state: HVACMode | str | None = None _on: str | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Initialize the climate device.""" diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 4be63f29c02..25422004797 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -34,7 +34,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util @@ -72,9 +72,9 @@ def _ws_get_significant_states( significant_changes_only: bool, minimal_response: bool, no_attributes: bool, -) -> str: +) -> bytes: """Fetch history significant_states and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, history.get_significant_states( @@ -201,9 +201,9 @@ def _generate_websocket_response( start_time: dt, end_time: dt, states: MutableMapping[str, list[dict[str, Any]]], -) -> str: +) -> bytes: """Generate a websocket response.""" - return JSON_DUMP( + return json_bytes( messages.event_message( msg_id, _generate_stream_message(states, start_time, end_time) ) @@ -221,7 +221,7 @@ def _generate_historical_response( minimal_response: bool, no_attributes: bool, send_empty: bool, -) -> tuple[float, dt | None, str | None]: +) -> tuple[float, dt | None, bytes | None]: """Generate a historical response.""" states = cast( MutableMapping[str, list[dict[str, Any]]], @@ -302,13 +302,9 @@ def _history_compressed_state(state: State, no_attributes: bool) -> dict[str, An comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state.state} if not no_attributes or state.domain in history.NEED_ATTRIBUTE_DOMAINS: comp_state[COMPRESSED_STATE_ATTRIBUTES] = state.attributes - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( - state.last_updated - ) + comp_state[COMPRESSED_STATE_LAST_UPDATED] = state.last_updated_timestamp if state.last_changed != state.last_updated: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = dt_util.utc_to_timestamp( - state.last_changed - ) + comp_state[COMPRESSED_STATE_LAST_CHANGED] = state.last_changed_timestamp return comp_state @@ -350,7 +346,7 @@ async def _async_events_consumer( if history_states := _events_to_compressed_states(events, no_attributes): connection.send_message( - JSON_DUMP( + json_bytes( messages.event_message( msg_id, {"states": history_states}, diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index baa39468bc1..7f318b03e06 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod import datetime +from typing import Any, TypeVar import voluptuous as vol @@ -53,8 +54,10 @@ UNITS: dict[str, str] = { } ICON = "mdi:chart-line" +_T = TypeVar("_T", bound=dict[str, Any]) -def exactly_two_period_keys(conf): + +def exactly_two_period_keys(conf: _T) -> _T: """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: raise vol.Invalid( diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 99de8b99675..8085719d8c5 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -92,8 +92,12 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] _attr_preset_modes = [PRESET_BOOST, PRESET_NONE] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hive_session, hive_device): """Initialize the Climate device.""" diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py new file mode 100644 index 00000000000..a83c1dd2d89 --- /dev/null +++ b/homeassistant/components/hko/__init__.py @@ -0,0 +1,41 @@ +"""The Hong Kong Observatory integration.""" +from __future__ import annotations + +from hko import LOCATIONS + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LOCATION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_DISTRICT, DOMAIN, KEY_DISTRICT, KEY_LOCATION +from .coordinator import HKOUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Hong Kong Observatory from a config entry.""" + + location = entry.data[CONF_LOCATION] + district = next( + (item for item in LOCATIONS if item[KEY_LOCATION] == location), + {KEY_DISTRICT: DEFAULT_DISTRICT}, + )[KEY_DISTRICT] + websession = async_get_clientsession(hass) + + coordinator = HKOUpdateCoordinator(hass, websession, district, location) + 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/hko/config_flow.py b/homeassistant/components/hko/config_flow.py new file mode 100644 index 00000000000..21697d2dd53 --- /dev/null +++ b/homeassistant/components/hko/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Hong Kong Observatory integration.""" +from __future__ import annotations + +from asyncio import timeout +from typing import Any + +from hko import HKO, LOCATIONS, HKOError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LOCATION +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION + + +def get_loc_name(item): + """Return an array of supported locations.""" + return item[KEY_LOCATION] + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION, default=DEFAULT_LOCATION): SelectSelector( + SelectSelectorConfig(options=list(map(get_loc_name, LOCATIONS)), sort=True) + ) + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hong Kong Observatory.""" + + 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=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + websession = async_get_clientsession(self.hass) + hko = HKO(websession) + async with timeout(60): + await hko.weather(API_RHRREAD) + + except HKOError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_LOCATION], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LOCATION], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/hko/const.py b/homeassistant/components/hko/const.py new file mode 100644 index 00000000000..a9a554850b0 --- /dev/null +++ b/homeassistant/components/hko/const.py @@ -0,0 +1,74 @@ +"""Constants for the Hong Kong Observatory integration.""" +from hko import LOCATIONS + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) + +DOMAIN = "hko" + +DISTRICT = "name" + +KEY_LOCATION = "LOCATION" +KEY_DISTRICT = "DISTRICT" + +DEFAULT_LOCATION = LOCATIONS[0][KEY_LOCATION] +DEFAULT_DISTRICT = LOCATIONS[0][KEY_DISTRICT] + +ATTRIBUTION = "Data provided by the Hong Kong Observatory" +MANUFACTURER = "Hong Kong Observatory" + +API_CURRENT = "current" +API_FORECAST = "forecast" +API_WEATHER_FORECAST = "weatherForecast" +API_FORECAST_DATE = "forecastDate" +API_FORECAST_ICON = "ForecastIcon" +API_FORECAST_WEATHER = "forecastWeather" +API_FORECAST_MAX_TEMP = "forecastMaxtemp" +API_FORECAST_MIN_TEMP = "forecastMintemp" +API_CONDITION = "condition" +API_TEMPERATURE = "temperature" +API_HUMIDITY = "humidity" +API_PLACE = "place" +API_DATA = "data" +API_VALUE = "value" +API_RHRREAD = "rhrread" + +WEATHER_INFO_RAIN = "rain" +WEATHER_INFO_SNOW = "snow" +WEATHER_INFO_WIND = "wind" +WEATHER_INFO_MIST = "mist" +WEATHER_INFO_CLOUD = "cloud" +WEATHER_INFO_THUNDERSTORM = "thunderstorm" +WEATHER_INFO_SHOWER = "shower" +WEATHER_INFO_ISOLATED = "isolated" +WEATHER_INFO_HEAVY = "heavy" +WEATHER_INFO_SUNNY = "sunny" +WEATHER_INFO_FINE = "fine" +WEATHER_INFO_AT_TIMES_AT_FIRST = "at times at first" +WEATHER_INFO_OVERCAST = "overcast" +WEATHER_INFO_INTERVAL = "interval" +WEATHER_INFO_PERIOD = "period" +WEATHER_INFO_FOG = "FOG" + +ICON_CONDITION_MAP = { + ATTR_CONDITION_SUNNY: [50], + ATTR_CONDITION_PARTLYCLOUDY: [51, 52, 53, 54, 76], + ATTR_CONDITION_CLOUDY: [60, 61], + ATTR_CONDITION_RAINY: [62, 63], + ATTR_CONDITION_POURING: [64], + ATTR_CONDITION_LIGHTNING_RAINY: [65], + ATTR_CONDITION_CLEAR_NIGHT: [70, 71, 72, 73, 74, 75, 77], + ATTR_CONDITION_SNOWY_RAINY: [7, 14, 15, 27, 37], + ATTR_CONDITION_WINDY: [80], + ATTR_CONDITION_FOG: [83, 84], +} diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py new file mode 100644 index 00000000000..05280c4a3bd --- /dev/null +++ b/homeassistant/components/hko/coordinator.py @@ -0,0 +1,187 @@ +"""Weather data coordinator for the HKO API.""" +from asyncio import timeout +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientSession +from hko import HKO, HKOError + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_CURRENT, + API_DATA, + API_FORECAST, + API_FORECAST_DATE, + API_FORECAST_ICON, + API_FORECAST_MAX_TEMP, + API_FORECAST_MIN_TEMP, + API_FORECAST_WEATHER, + API_HUMIDITY, + API_PLACE, + API_TEMPERATURE, + API_VALUE, + API_WEATHER_FORECAST, + DOMAIN, + ICON_CONDITION_MAP, + WEATHER_INFO_AT_TIMES_AT_FIRST, + WEATHER_INFO_CLOUD, + WEATHER_INFO_FINE, + WEATHER_INFO_FOG, + WEATHER_INFO_HEAVY, + WEATHER_INFO_INTERVAL, + WEATHER_INFO_ISOLATED, + WEATHER_INFO_MIST, + WEATHER_INFO_OVERCAST, + WEATHER_INFO_PERIOD, + WEATHER_INFO_RAIN, + WEATHER_INFO_SHOWER, + WEATHER_INFO_SNOW, + WEATHER_INFO_SUNNY, + WEATHER_INFO_THUNDERSTORM, + WEATHER_INFO_WIND, +) + +_LOGGER = logging.getLogger(__name__) + + +class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """HKO Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, session: ClientSession, district: str, location: str + ) -> None: + """Update data via library.""" + self.location = location + self.district = district + self.hko = HKO(session) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via HKO library.""" + try: + async with timeout(60): + rhrread = await self.hko.weather("rhrread") + fnd = await self.hko.weather("fnd") + except HKOError as error: + raise UpdateFailed(error) from error + return { + API_CURRENT: self._convert_current(rhrread), + API_FORECAST: [ + self._convert_forecast(item) for item in fnd[API_WEATHER_FORECAST] + ], + } + + def _convert_current(self, data: dict[str, Any]) -> dict[str, Any]: + """Return temperature and humidity in the appropriate format.""" + current = { + API_HUMIDITY: data[API_HUMIDITY][API_DATA][0][API_VALUE], + API_TEMPERATURE: next( + ( + item[API_VALUE] + for item in data[API_TEMPERATURE][API_DATA] + if item[API_PLACE] == self.location + ), + 0, + ), + } + return current + + def _convert_forecast(self, data: dict[str, Any]) -> dict[str, Any]: + """Return daily forecast in the appropriate format.""" + date = data[API_FORECAST_DATE] + forecast = { + ATTR_FORECAST_CONDITION: self._convert_icon_condition( + data[API_FORECAST_ICON], data[API_FORECAST_WEATHER] + ), + ATTR_FORECAST_TEMP: data[API_FORECAST_MAX_TEMP][API_VALUE], + ATTR_FORECAST_TEMP_LOW: data[API_FORECAST_MIN_TEMP][API_VALUE], + ATTR_FORECAST_TIME: f"{date[0:4]}-{date[4:6]}-{date[6:8]}T00:00:00+08:00", + } + return forecast + + def _convert_icon_condition(self, icon_code: int, info: str) -> str: + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return self._convert_info_condition(info) + + def _convert_info_condition(self, info: str) -> str: + """Return the condition corresponding to the weather info.""" + info = info.lower() + if WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_HAIL + if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_SNOWY_RAINY + if WEATHER_INFO_SNOW in info: + return ATTR_CONDITION_SNOWY + if WEATHER_INFO_FOG in info or WEATHER_INFO_MIST in info: + return ATTR_CONDITION_FOG + if WEATHER_INFO_WIND in info and WEATHER_INFO_CLOUD in info: + return ATTR_CONDITION_WINDY_VARIANT + if WEATHER_INFO_WIND in info: + return ATTR_CONDITION_WINDY + if WEATHER_INFO_THUNDERSTORM in info and WEATHER_INFO_ISOLATED not in info: + return ATTR_CONDITION_LIGHTNING_RAINY + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_HEAVY in info + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + and WEATHER_INFO_AT_TIMES_AT_FIRST not in info + ): + return ATTR_CONDITION_POURING + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + ): + return ATTR_CONDITION_RAINY + if (WEATHER_INFO_CLOUD in info or WEATHER_INFO_OVERCAST in info) and not ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_CLOUDY + if (WEATHER_INFO_SUNNY in info) and ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_PARTLYCLOUDY + if ( + WEATHER_INFO_SUNNY in info or WEATHER_INFO_FINE in info + ) and WEATHER_INFO_SHOWER not in info: + return ATTR_CONDITION_SUNNY + return ATTR_CONDITION_PARTLYCLOUDY diff --git a/homeassistant/components/hko/manifest.json b/homeassistant/components/hko/manifest.json new file mode 100644 index 00000000000..74718bb98c2 --- /dev/null +++ b/homeassistant/components/hko/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hko", + "name": "Hong Kong Observatory", + "codeowners": ["@MisterCommand"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hko", + "iot_class": "cloud_polling", + "requirements": ["hko==0.3.2"] +} diff --git a/homeassistant/components/hko/strings.json b/homeassistant/components/hko/strings.json new file mode 100644 index 00000000000..a537c864528 --- /dev/null +++ b/homeassistant/components/hko/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "Please select a location to use for weather forecasting.", + "data": { + "location": "[%key:common::config_flow::data::location%]" + } + } + } + } +} diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py new file mode 100644 index 00000000000..f4a784c5308 --- /dev/null +++ b/homeassistant/components/hko/weather.py @@ -0,0 +1,75 @@ +"""Support for the HKO service.""" +from homeassistant.components.weather import ( + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + API_CONDITION, + API_CURRENT, + API_FORECAST, + API_HUMIDITY, + API_TEMPERATURE, + ATTRIBUTION, + DOMAIN, + MANUFACTURER, +) +from .coordinator import HKOUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a HKO weather entity from a config_entry.""" + assert config_entry.unique_id is not None + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([HKOEntity(unique_id, coordinator)], False) + + +class HKOEntity(CoordinatorEntity[HKOUpdateCoordinator], WeatherEntity): + """Define a HKO entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + _attr_attribution = ATTRIBUTION + + def __init__(self, unique_id: str, coordinator: HKOUpdateCoordinator) -> None: + """Initialise the weather platform.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=MANUFACTURER, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def condition(self) -> str: + """Return the current condition.""" + return self.coordinator.data[API_FORECAST][0][API_CONDITION] + + @property + def native_temperature(self) -> int: + """Return the temperature.""" + return self.coordinator.data[API_CURRENT][API_TEMPERATURE] + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self.coordinator.data[API_CURRENT][API_HUMIDITY] + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast data.""" + return self.coordinator.data[API_FORECAST] diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 33268de92b6..07da19167d7 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -47,7 +47,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) try: - locale = Locale(self.hass.config.language.replace("-", "_")) + locale = Locale.parse(self.hass.config.language, sep="-") except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" @@ -87,7 +87,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - locale = Locale(self.hass.config.language.replace("-", "_")) + locale = Locale.parse(self.hass.config.language, sep="-") except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 7417cc5cd51..0608f8c404e 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -1,9 +1,9 @@ { "domain": "holiday", "name": "Holiday", - "codeowners": ["@jrieger"], + "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"] + "requirements": ["holidays==0.42", "babel==2.13.1"] } diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index 4762a48c659..53d403e790e 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -1,4 +1,5 @@ { + "title": "Holiday", "config": { "abort": { "already_configured": "Already configured. Only a single configuration for country/province combination possible." diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 7377c4b60d0..79303725249 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -7,25 +7,14 @@ import logging from requests import HTTPError import voluptuous as vol -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_DEVICE, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ATTR_DEVICE_ID, CONF_DEVICE, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -51,20 +40,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=1) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) SERVICE_SETTING_SCHEMA = vol.Schema( { @@ -118,37 +94,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} - if DOMAIN in config: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Home Connect integration in YAML is deprecated and " - "will be removed in a future release; Your existing OAuth " - "Application Credentials have been imported into the UI " - "automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Home Connect", - }, - ) - async def _async_service_program(call, method): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 9eabc9b5d43..5b0a9e3e9d8 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,10 +10,14 @@ BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" +BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" +BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" +BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" +BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" + COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 07edfb4bd4b..a01cae5862a 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -10,7 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ATTR_VALUE, BSH_OPERATION_STATE, DOMAIN +from .const import ( + ATTR_VALUE, + BSH_OPERATION_STATE, + BSH_OPERATION_STATE_FINISHED, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_RUN, + DOMAIN, +) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) @@ -69,9 +76,20 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - else: + elif ( + BSH_OPERATION_STATE in status + and ATTR_VALUE in status[BSH_OPERATION_STATE] + and status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ): seconds = self._sign * float(status[self._key][ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) + else: + self._attr_native_value = None else: self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 2ed37480705..e917ab6f2c9 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -1,208 +1 @@ -"""The Legrand Home+ Control integration.""" -import asyncio -from datetime import timedelta -import logging - -from homepluscontrol.homeplusapi import HomePlusControlApiError -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - dispatcher, -) -from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from . import config_flow, helpers -from .api import HomePlusControlAsyncApi -from .const import ( - API, - CONF_SUBSCRIPTION_KEY, - DATA_COORDINATOR, - DISPATCHER_REMOVERS, - DOMAIN, - ENTITY_UIDS, - SIGNAL_ADD_ENTITIES, -) - -# Configuration schema for component in configuration.yaml -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Required(CONF_SUBSCRIPTION_KEY): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -# The Legrand Home+ Control platform is currently limited to "switch" entities -PLATFORMS = [Platform.SWITCH] - -_LOGGER = logging.getLogger(__name__) - -_ISSUE_MOVE_TO_NETATMO = "move_to_netatmo" - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Legrand Home+ Control component from configuration.yaml.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - async_create_issue( - hass, - DOMAIN, - _ISSUE_MOVE_TO_NETATMO, - is_fixable=False, - is_persistent=False, - breaks_in_ha_version="2023.12.0", # Netatmo decided to shutdown the api in december - severity=IssueSeverity.WARNING, - translation_key=_ISSUE_MOVE_TO_NETATMO, - translation_placeholders={ - "url": "https://www.home-assistant.io/integrations/netatmo/" - }, - ) - - # Register the implementation from the config information - config_flow.HomePlusControlFlowHandler.async_register_implementation( - hass, - helpers.HomePlusControlOAuth2Implementation(hass, config[DOMAIN]), - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Legrand Home+ Control from a config entry.""" - hass_entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) - - async_create_issue( - hass, - DOMAIN, - _ISSUE_MOVE_TO_NETATMO, - is_fixable=False, - is_persistent=False, - breaks_in_ha_version="2023.12.0", # Netatmo decided to shutdown the api in december - severity=IssueSeverity.WARNING, - translation_key=_ISSUE_MOVE_TO_NETATMO, - translation_placeholders={ - "url": "https://www.home-assistant.io/integrations/netatmo/" - }, - ) - - # Retrieve the registered implementation - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - - # Using an aiohttp-based API lib, so rely on async framework - # Add the API object to the domain's data in HA - api = hass_entry_data[API] = HomePlusControlAsyncApi(hass, entry, implementation) - - # Set of entity unique identifiers of this integration - uids: set[str] = set() - hass_entry_data[ENTITY_UIDS] = uids - - # Integration dispatchers - hass_entry_data[DISPATCHER_REMOVERS] = [] - - device_registry = async_get_device_registry(hass) - - # Register the Data Coordinator with the integration - async def async_update_data(): - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - return await api.async_get_modules() - except HomePlusControlApiError as err: - raise UpdateFailed( - f"Error communicating with API: {err} [{type(err)}]" - ) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="home_plus_control_module", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=300), - ) - hass_entry_data[DATA_COORDINATOR] = coordinator - - @callback - def _async_update_entities(): - """Process entities and add or remove them based after an update.""" - if not (module_data := coordinator.data): - return - - # Remove obsolete entities from Home Assistant - entity_uids_to_remove = uids - set(module_data) - for uid in entity_uids_to_remove: - uids.remove(uid) - device = device_registry.async_get_device(identifiers={(DOMAIN, uid)}) - device_registry.async_remove_device(device.id) - - # Send out signal for new entity addition to Home Assistant - new_entity_uids = set(module_data) - uids - if new_entity_uids: - uids.update(new_entity_uids) - dispatcher.async_dispatcher_send( - hass, - SIGNAL_ADD_ENTITIES, - new_entity_uids, - coordinator, - ) - - entry.async_on_unload(coordinator.async_add_listener(_async_update_entities)) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Only refresh the coordinator after all platforms are loaded. - await coordinator.async_refresh() - - return True - - -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload the Legrand Home+ Control config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - # Unsubscribe the config_entry signal dispatcher connections - dispatcher_removers = hass.data[DOMAIN][config_entry.entry_id].pop( - "dispatcher_removers" - ) - for remover in dispatcher_removers: - remover() - - # And finally unload the domain config entry data - hass.data[DOMAIN].pop(config_entry.entry_id) - - async_delete_issue(hass, DOMAIN, _ISSUE_MOVE_TO_NETATMO) - - return unload_ok +"""Virtual integration: Legrand Home+ Control.""" diff --git a/homeassistant/components/home_plus_control/api.py b/homeassistant/components/home_plus_control/api.py deleted file mode 100644 index 9f092b28920..00000000000 --- a/homeassistant/components/home_plus_control/api.py +++ /dev/null @@ -1,58 +0,0 @@ -"""API for Legrand Home+ Control bound to Home Assistant OAuth.""" -from homepluscontrol.homeplusapi import HomePlusControlAPI - -from homeassistant import config_entries, core -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow - -from .const import DEFAULT_UPDATE_INTERVALS -from .helpers import HomePlusControlOAuth2Implementation - - -class HomePlusControlAsyncApi(HomePlusControlAPI): - """Legrand Home+ Control object that interacts with the OAuth2-based API of the provider. - - This API is bound the HomeAssistant Config Entry that corresponds to this component. - - Attributes:. - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this API. - implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA and - token refresh. - _oauth_session (OAuth2Session): OAuth2Session object within implementation. - """ - - def __init__( - self, - hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, - ) -> None: - """Initialize the HomePlusControlAsyncApi object. - - Initialize the authenticated API for the Legrand Home+ Control component. - - Args:. - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this API. - implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA - and token refresh. - """ - self._oauth_session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - - assert isinstance(implementation, HomePlusControlOAuth2Implementation) - - # Create the API authenticated client - external library - super().__init__( - subscription_key=implementation.subscription_key, - oauth_client=aiohttp_client.async_get_clientsession(hass), - update_intervals=DEFAULT_UPDATE_INTERVALS, - ) - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/home_plus_control/config_flow.py b/homeassistant/components/home_plus_control/config_flow.py deleted file mode 100644 index bf99da7ab73..00000000000 --- a/homeassistant/components/home_plus_control/config_flow.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Config flow for Legrand Home+ Control.""" -import logging - -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import DOMAIN - - -class HomePlusControlFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): - """Config flow to handle Home+ Control OAuth2 authentication.""" - - DOMAIN = DOMAIN - - # Pick the Cloud Poll class - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_step_user(self, user_input=None): - """Handle a flow start initiated by the user.""" - await self.async_set_unique_id(DOMAIN) - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - return await super().async_step_user(user_input) diff --git a/homeassistant/components/home_plus_control/const.py b/homeassistant/components/home_plus_control/const.py deleted file mode 100644 index 0ebae0bef20..00000000000 --- a/homeassistant/components/home_plus_control/const.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Constants for the Legrand Home+ Control integration.""" -API = "api" -CONF_SUBSCRIPTION_KEY = "subscription_key" -CONF_PLANT_UPDATE_INTERVAL = "plant_update_interval" -CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL = "plant_topology_update_interval" -CONF_MODULE_STATUS_UPDATE_INTERVAL = "module_status_update_interval" - -DATA_COORDINATOR = "coordinator" -DOMAIN = "home_plus_control" -ENTITY_UIDS = "entity_unique_ids" -DISPATCHER_REMOVERS = "dispatcher_removers" - -# Legrand Model Identifiers - https://developer.legrand.com/documentation/product-cluster-list/# -HW_TYPE = { - "NLC": "NLC - Cable Outlet", - "NLF": "NLF - On-Off Dimmer Switch w/o Neutral", - "NLP": "NLP - Socket (Connected) Outlet", - "NLPM": "NLPM - Mobile Socket Outlet", - "NLM": "NLM - Micromodule Switch", - "NLV": "NLV - Shutter Switch with Neutral", - "NLLV": "NLLV - Shutter Switch with Level Control", - "NLL": "NLL - On-Off Toggle Switch with Neutral", - "NLT": "NLT - Remote Switch", - "NLD": "NLD - Double Gangs On-Off Remote Switch", -} - -# Legrand OAuth2 URIs -OAUTH2_AUTHORIZE = "https://partners-login.eliotbylegrand.com/authorize" -OAUTH2_TOKEN = "https://partners-login.eliotbylegrand.com/token" - -# The Legrand Home+ Control API has very limited request quotas - at the time of writing, it is -# limited to 500 calls per day (resets at 00:00) - so we want to keep updates to a minimum. -DEFAULT_UPDATE_INTERVALS = { - # Seconds between API checks for plant information updates. This is expected to change very - # little over time because a user's plants (homes) should rarely change. - CONF_PLANT_UPDATE_INTERVAL: 7200, # 120 minutes - # Seconds between API checks for plant topology updates. This is expected to change little - # over time because the modules in the user's plant should be relatively stable. - CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL: 3600, # 60 minutes - # Seconds between API checks for module status updates. This can change frequently so we - # check often - CONF_MODULE_STATUS_UPDATE_INTERVAL: 300, # 5 minutes -} - -SIGNAL_ADD_ENTITIES = "home_plus_control_add_entities_signal" diff --git a/homeassistant/components/home_plus_control/helpers.py b/homeassistant/components/home_plus_control/helpers.py deleted file mode 100644 index f5687a23c66..00000000000 --- a/homeassistant/components/home_plus_control/helpers.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Helper classes and functions for the Legrand Home+ Control integration.""" -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import CONF_SUBSCRIPTION_KEY, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN - - -class HomePlusControlOAuth2Implementation( - config_entry_oauth2_flow.LocalOAuth2Implementation -): - """OAuth2 implementation that extends the HomeAssistant local implementation. - - It provides the name of the integration and adds support for the subscription key. - - Attributes: - hass (HomeAssistant): HomeAssistant core object. - client_id (str): Client identifier assigned by the API provider when registering an app. - client_secret (str): Client secret assigned by the API provider when registering an app. - subscription_key (str): Subscription key obtained from the API provider. - authorize_url (str): Authorization URL initiate authentication flow. - token_url (str): URL to retrieve access/refresh tokens. - name (str): Name of the implementation (appears in the HomeAssistant GUI). - """ - - def __init__( - self, - hass: HomeAssistant, - config_data: dict, - ) -> None: - """HomePlusControlOAuth2Implementation Constructor. - - Initialize the authentication implementation for the Legrand Home+ Control API. - - Args: - hass (HomeAssistant): HomeAssistant core object. - config_data (dict): Configuration data that complies with the config Schema - of this component. - """ - super().__init__( - hass=hass, - domain=DOMAIN, - client_id=config_data[CONF_CLIENT_ID], - client_secret=config_data[CONF_CLIENT_SECRET], - authorize_url=OAUTH2_AUTHORIZE, - token_url=OAUTH2_TOKEN, - ) - self.subscription_key = config_data[CONF_SUBSCRIPTION_KEY] - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Home+ Control" diff --git a/homeassistant/components/home_plus_control/manifest.json b/homeassistant/components/home_plus_control/manifest.json index f225c23fedf..78a3633ca8d 100644 --- a/homeassistant/components/home_plus_control/manifest.json +++ b/homeassistant/components/home_plus_control/manifest.json @@ -1,11 +1,6 @@ { "domain": "home_plus_control", "name": "Legrand Home+ Control", - "codeowners": ["@chemaaa"], - "config_flow": true, - "dependencies": ["auth"], - "documentation": "https://www.home-assistant.io/integrations/home_plus_control", - "iot_class": "cloud_polling", - "loggers": ["homepluscontrol"], - "requirements": ["homepluscontrol==0.0.5"] + "integration_type": "virtual", + "supported_by": "netatmo" } diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json deleted file mode 100644 index 13a7102827c..00000000000 --- a/homeassistant/components/home_plus_control/strings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - } - }, - "issues": { - "move_to_netatmo": { - "title": "Legrand Home+ Control deprecation", - "description": "Home Assistant has been informed that the platform the Legrand Home+ Control integration is using, will be shutting down upcoming December.\n\nOnce that happens, it means this integration is no longer functional. We advise you to remove this integration and switch to the [Netatmo]({url}) integration, which provides a replacement for controlling your Legrand Home+ Control devices." - } - } -} diff --git a/homeassistant/components/home_plus_control/switch.py b/homeassistant/components/home_plus_control/switch.py deleted file mode 100644 index ef2c1447bf4..00000000000 --- a/homeassistant/components/home_plus_control/switch.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Legrand Home+ Control Switch Entity Module that uses the HomeAssistant DataUpdateCoordinator.""" -from functools import partial -from typing import Any - -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import dispatcher -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DISPATCHER_REMOVERS, DOMAIN, HW_TYPE, SIGNAL_ADD_ENTITIES - - -@callback -def add_switch_entities(new_unique_ids, coordinator, add_entities): - """Add switch entities to the platform. - - Args: - new_unique_ids (set): Unique identifiers of entities to be added to Home Assistant. - coordinator (DataUpdateCoordinator): Data coordinator of this platform. - add_entities (function): Method called to add entities to Home Assistant. - """ - new_entities = [] - for uid in new_unique_ids: - new_ent = HomeControlSwitchEntity(coordinator, uid) - new_entities.append(new_ent) - add_entities(new_entities) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Legrand Home+ Control Switch platform in HomeAssistant. - - Args: - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this platform. - async_add_entities (function): Function called to add entities of this platform. - """ - partial_add_switch_entities = partial( - add_switch_entities, add_entities=async_add_entities - ) - # Connect the dispatcher for the switch platform - hass.data[DOMAIN][config_entry.entry_id][DISPATCHER_REMOVERS].append( - dispatcher.async_dispatcher_connect( - hass, SIGNAL_ADD_ENTITIES, partial_add_switch_entities - ) - ) - - -class HomeControlSwitchEntity(CoordinatorEntity, SwitchEntity): - """Entity that represents a Legrand Home+ Control switch. - - It extends the HomeAssistant-provided classes of the CoordinatorEntity and the SwitchEntity. - - The CoordinatorEntity class provides: - should_poll - async_update - async_added_to_hass - - The SwitchEntity class provides the functionality of a ToggleEntity and additional power - consumption methods and state attributes. - """ - - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, coordinator, idx): - """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - self.idx = idx - self.module = self.coordinator.data[self.idx] - - @property - def unique_id(self): - """ID (unique) of the device.""" - return self.idx - - @property - def device_info(self) -> DeviceInfo: - """Device information.""" - return DeviceInfo( - identifiers={ - # Unique identifiers within the domain - (DOMAIN, self.unique_id) - }, - manufacturer="Legrand", - model=HW_TYPE.get(self.module.hw_type), - name=self.module.name, - sw_version=self.module.fw, - ) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - if self.module.device == "plug": - return SwitchDeviceClass.OUTLET - return SwitchDeviceClass.SWITCH - - @property - def available(self) -> bool: - """Return if entity is available. - - This is the case when the coordinator is able to update the data successfully - AND the switch entity is reachable. - - This method overrides the one of the CoordinatorEntity - """ - return self.coordinator.last_update_success and self.module.reachable - - @property - def is_on(self): - """Return entity state.""" - return self.module.status == "on" - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the light on.""" - # Do the turning on. - await self.module.turn_on() - # Update the data - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self.module.turn_off() - # Update the data - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index c978a7d4320..02a86150ff0 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -94,8 +94,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no sorted(all_referenced), lambda item: ha.split_entity_id(item)[0] ) - tasks = [] - unsupported_entities = set() + tasks: list[Coroutine[Any, Any, ha.ServiceResponse]] = [] + unsupported_entities: set[str] = set() for domain, ent_ids in by_domain: # This leads to endless loop. @@ -298,7 +298,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: """Service handler for reloading a config entry.""" - reload_entries = set() + reload_entries: set[str] = set() if ATTR_ENTRY_ID in call.data: reload_entries.add(call.data[ATTR_ENTRY_ID]) reload_entries.update(await async_extract_config_entry_ids(hass, call)) @@ -344,7 +344,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no f"configuration is not valid: {errors}" ) - services = hass.services.async_services() + services = hass.services.async_services_internal() tasks = [ hass.services.async_call( domain, SERVICE_RELOAD, context=call.context, blocking=True @@ -376,7 +376,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no return True -async def _async_stop(hass: ha.HomeAssistant, restart: bool): +async def _async_stop(hass: ha.HomeAssistant, restart: bool) -> None: """Stop home assistant.""" exit_code = RESTART_EXIT_CODE if restart else 0 # Track trask in hass.data. No need to cleanup, we're stopping. diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 926ab5025f6..38c7f8e8128 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -12,7 +12,7 @@ from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import get_device_class @@ -129,10 +129,17 @@ class ExposedEntities: @callback def async_listen_entity_updates( self, assistant: str, listener: Callable[[], None] - ) -> None: + ) -> CALLBACK_TYPE: """Listen for updates to entity expose settings.""" + + def unsubscribe() -> None: + """Stop listening to entity updates.""" + self._listeners[assistant].remove(listener) + self._listeners.setdefault(assistant, []).append(listener) + return unsubscribe + @callback def async_set_assistant_option( self, assistant: str, entity_id: str, key: str, value: Any @@ -475,7 +482,7 @@ def ws_expose_new_entities_get( def ws_expose_new_entities_set( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose new entities to an assistatant.""" + """Expose new entities to an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) connection.send_result(msg["id"]) @@ -484,10 +491,10 @@ def ws_expose_new_entities_set( @callback def async_listen_entity_updates( hass: HomeAssistant, assistant: str, listener: Callable[[], None] -) -> None: +) -> CALLBACK_TYPE: """Listen for updates to entity expose settings.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_listen_entity_updates(assistant, listener) + return exposed_entities.async_listen_entity_updates(assistant, listener) @callback diff --git a/homeassistant/components/homeassistant/logbook.py b/homeassistant/components/homeassistant/logbook.py index 229fb24cb27..60e8794799d 100644 --- a/homeassistant/components/homeassistant/logbook.py +++ b/homeassistant/components/homeassistant/logbook.py @@ -28,7 +28,7 @@ def async_describe_events( @callback def async_describe_hass_event(event: Event) -> dict[str, str]: - """Describe homeassisant logbook event.""" + """Describe homeassistant logbook event.""" return { LOGBOOK_ENTRY_NAME: "Home Assistant", LOGBOOK_ENTRY_MESSAGE: EVENT_TO_NAME[event.event_type], diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 9abfefc996f..f8fd901a18a 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -135,7 +135,7 @@ class SceneConfig(NamedTuple): id: str | None name: str icon: str | None - states: dict + states: dict[str, State] @callback diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 862ac12cefb..e2a6fc1c9e7 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -46,6 +46,10 @@ } } } + }, + "config_entry_reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Reauthentication is needed" } }, "system_health": { diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index 4006228de25..488328b6e4e 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -1,4 +1,8 @@ """Provide info to system health.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import system_info @@ -12,7 +16,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" info = await system_info.async_get_system_info(hass) diff --git a/homeassistant/components/homeassistant/trigger.py b/homeassistant/components/homeassistant/trigger.py index 3160af58079..401da9d01e7 100644 --- a/homeassistant/components/homeassistant/trigger.py +++ b/homeassistant/components/homeassistant/trigger.py @@ -23,7 +23,7 @@ async def async_validate_trigger_config( if hasattr(platform, "async_validate_trigger_config"): return await platform.async_validate_trigger_config(hass, config) - return platform.TRIGGER_SCHEMA(config) + return platform.TRIGGER_SCHEMA(config) # type: ignore[no-any-return] async def async_attach_trigger( diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index be514fd24ad..37a91d06d1a 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -115,11 +115,15 @@ async def async_attach_trigger( if event_context_items: # Fast path for simple items comparison - if not (event.context.as_dict().items() >= event_context_items): + # This is safe because we do not mutate the event context + # pylint: disable-next=protected-access + if not (event.context._as_dict.items() >= event_context_items): return False elif event_context_schema: # Slow path for schema validation - event_context_schema(dict(event.context.as_dict())) + # This is safe because we make a copy of the event context + # pylint: disable-next=protected-access + event_context_schema(dict(event.context._as_dict)) except vol.Invalid: # If event doesn't match, skip event return False diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index d822cd523fc..dad57bbcdb3 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -1,5 +1,10 @@ """Offer numeric state listening automation rules.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta import logging +from typing import Any, TypeVar import voluptuous as vol @@ -13,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers import ( condition, config_validation as cv, @@ -21,14 +26,17 @@ from homeassistant.helpers import ( template, ) from homeassistant.helpers.event import ( + EventStateChangedData, async_track_same_state, async_track_state_change_event, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType + +_T = TypeVar("_T", bound=dict[str, Any]) -def validate_above_below(value): +def validate_above_below(value: _T) -> _T: """Validate that above and below can co-exist.""" above = value.get(CONF_ABOVE) below = value.get(CONF_BELOW) @@ -96,9 +104,9 @@ async def async_attach_trigger( time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) - unsub_track_same = {} - armed_entities = set() - period: dict = {} + unsub_track_same: dict[str, Callable[[], None]] = {} + armed_entities: set[str] = set() + period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action, f"numeric state trigger {trigger_info}") @@ -108,7 +116,7 @@ async def async_attach_trigger( if value_template is not None: value_template.hass = hass - def variables(entity_id): + def variables(entity_id: str) -> dict[str, Any]: """Return a dict with trigger variables.""" trigger_info = { "trigger": { @@ -122,7 +130,9 @@ async def async_attach_trigger( return {**_variables, **trigger_info} @callback - def check_numeric_state(entity_id, from_s, to_s): + def check_numeric_state( + entity_id: str, from_s: State | None, to_s: str | State | None + ) -> bool: """Return whether the criteria are met, raise ConditionError if unknown.""" return condition.async_numeric_state( hass, to_s, below, above, value_template, variables(entity_id), attribute @@ -141,14 +151,17 @@ async def async_attach_trigger( ) @callback - def state_automation_listener(event): + def state_automation_listener(event: EventType[EventStateChangedData]) -> None: """Listen for state changes and calls action.""" - entity_id = event.data.get("entity_id") - from_s = event.data.get("old_state") - to_s = event.data.get("new_state") + entity_id = event.data["entity_id"] + from_s = event.data["old_state"] + to_s = event.data["new_state"] + + if to_s is None: + return @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -169,7 +182,9 @@ async def async_attach_trigger( ) @callback - def check_numeric_state_no_raise(entity_id, from_s, to_s): + def check_numeric_state_no_raise( + entity_id: str, from_s: State | None, to_s: State | None + ) -> bool: """Return True if the criteria are now met, False otherwise.""" try: return check_numeric_state(entity_id, from_s, to_s) @@ -216,7 +231,7 @@ async def async_attach_trigger( unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback - def async_remove(): + def async_remove() -> None: """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 2cac07e7cd9..061c2468c30 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -1,6 +1,7 @@ """Offer state listening automation rules.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging @@ -114,7 +115,7 @@ async def async_attach_trigger( match_all = all( item not in config for item in (CONF_FROM, CONF_NOT_FROM, CONF_NOT_TO, CONF_TO) ) - unsub_track_same = {} + unsub_track_same: dict[str, Callable[[], None]] = {} period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action, f"state trigger {trigger_info}") @@ -158,7 +159,7 @@ async def async_attach_trigger( return @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -201,7 +202,7 @@ async def async_attach_trigger( ) return - def _check_same_state(_, _2, new_st: State | None) -> bool: + def _check_same_state(_: str, _2: State | None, new_st: State | None) -> bool: if new_st is None: return False @@ -227,7 +228,7 @@ async def async_attach_trigger( unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback - def async_remove(): + def async_remove() -> None: """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5b3cd8590a7..3cb8809a7ad 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -53,7 +53,9 @@ async def async_attach_trigger( job = HassJob(action, f"time trigger {trigger_info}") @callback - def time_automation_listener(description, now, *, entity_id=None): + def time_automation_listener( + description: str, now: datetime, *, entity_id: str | None = None + ) -> None: """Listen for time changes and calls action.""" hass.async_run_hass_job( job, @@ -183,7 +185,7 @@ async def async_attach_trigger( ) @callback - def remove_track_time_changes(): + def remove_track_time_changes() -> None: """Remove tracked time changes.""" for remove in entities.values(): remove() diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 63f9b18cf9b..d8ac55eb04f 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -1,4 +1,9 @@ """Offer time listening automation rules.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -19,15 +24,15 @@ class TimePattern: :raises Invalid: If the value has a wrong format or is outside the range. """ - def __init__(self, maximum): + def __init__(self, maximum: int) -> None: """Initialize time pattern.""" self.maximum = maximum - def __call__(self, value): + def __call__(self, value: Any) -> str | int: """Validate input.""" try: if value == "*": - return value + return value # type: ignore[no-any-return] if isinstance(value, str) and value.startswith("/"): number = int(value[1:]) @@ -39,7 +44,7 @@ class TimePattern: except ValueError as err: raise vol.Invalid("invalid time_pattern value") from err - return value + return value # type: ignore[no-any-return] TRIGGER_SCHEMA = vol.All( @@ -75,7 +80,7 @@ async def async_attach_trigger( seconds = 0 @callback - def time_automation_listener(now): + def time_automation_listener(now: datetime) -> None: """Listen for time changes and calls action.""" hass.async_run_hass_job( job, diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 8241c171265..036eb07e067 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -124,7 +124,7 @@ class IntegrationAlert: return f"{self.filename}_{self.integration}" -class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): +class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): # pylint: disable=hass-enforce-coordinator-module """Data fetcher for HA Alerts.""" def __init__(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 7884d3f5617..ef953213fc8 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio -from collections.abc import Awaitable import dataclasses import logging from typing import Any, Protocol @@ -339,14 +338,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): """Return the correct flow manager.""" return self.hass.config_entries.options - async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: - try: - await awaitable - finally: - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) - async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" try: @@ -411,18 +402,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Install Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + if not self.install_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.install_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_install_addon_waiting() - ), + multipan_manager.async_install_addon_waiting(), "SiLabs Multiprotocol addon install", ) + + if not self.install_task.done(): return self.async_show_progress( step_id="install_addon", progress_action="install_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.install_task, ) try: @@ -518,27 +511,29 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Start Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + if not self.start_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.start_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_start_addon_waiting() - ) + multipan_manager.async_start_addon_waiting() ) + + if not self.start_task.done(): return self.async_show_progress( step_id="start_addon", progress_action="start_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.start_task, ) try: await self.start_task except (AddonError, AbortFlow) as err: - self.start_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -715,15 +710,16 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): if not self.install_task: self.install_task = self.hass.async_create_task( - self._resume_flow_when_done( - flasher_manager.async_install_addon_waiting() - ), + flasher_manager.async_install_addon_waiting(), "SiLabs Flasher addon install", ) + + if not self.install_task.done(): return self.async_show_progress( step_id="install_flasher_addon", progress_action="install_addon", description_placeholders={"addon_name": flasher_manager.addon_name}, + progress_task=self.install_task, ) try: @@ -800,19 +796,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Uninstall Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) if not self.stop_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.stop_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_uninstall_addon_waiting() - ), + multipan_manager.async_uninstall_addon_waiting(), "SiLabs Multiprotocol addon uninstall", ) + + if not self.stop_task.done(): return self.async_show_progress( step_id="uninstall_multiprotocol_addon", progress_action="uninstall_multiprotocol_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.stop_task, ) try: @@ -826,9 +823,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Start Silicon Labs Flasher add-on.""" + flasher_manager = get_flasher_addon_manager(self.hass) if not self.start_task: - flasher_manager = get_flasher_addon_manager(self.hass) async def start_and_wait_until_done() -> None: await flasher_manager.async_start_addon_waiting() @@ -837,13 +834,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): AddonState.NOT_RUNNING ) - self.start_task = self.hass.async_create_task( - self._resume_flow_when_done(start_and_wait_until_done()) - ) + self.start_task = self.hass.async_create_task(start_and_wait_until_done()) + + if not self.start_task.done(): return self.async_show_progress( step_id="start_flasher_addon", progress_action="start_flasher_addon", description_placeholders={"addon_name": flasher_manager.addon_name}, + progress_task=self.start_task, ) try: diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index cd90c4acf60..5812bc122c7 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -353,7 +353,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][entry.entry_id] = entry_data - if hass.state == CoreState.running: + if hass.state is CoreState.running: await homekit.async_start() else: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index a14e0add488..470bb78874c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -36,6 +36,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, __version__, ) @@ -506,7 +507,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] _LOGGER.debug("New_state: %s", new_state) # HomeKit handles unavailable state via the available property # so we should not propagate it here - if new_state is None or new_state.state == STATE_UNAVAILABLE: + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return battery_state = None battery_charging_state = None diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 1fc8b3f2430..4dcc6fb8f65 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -54,6 +54,8 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, ) from homeassistant.core import State, callback @@ -167,7 +169,9 @@ HEAT_COOL_DEADBAND = 5 def _hk_hvac_mode_from_state(state: State) -> int | None: """Return the equivalent HomeKit HVAC mode for a given state.""" - if not (hvac_mode := try_parse_enum(HVACMode, state.state)): + if (current_state := state.state) in (STATE_UNKNOWN, STATE_UNAVAILABLE): + return None + if not (hvac_mode := try_parse_enum(HVACMode, current_state)): _LOGGER.error( "%s: Received invalid HVAC mode: %s", state.entity_id, state.state ) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index d3e9a0f13a6..0ca85da3fa2 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 . import KNOWN_DEVICES 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 @@ -133,6 +139,13 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """The base HomeKit Controller climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False + + @callback + def _async_reconfigure(self) -> None: + """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.""" @@ -146,7 +159,7 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """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,10 +178,10 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): {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) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON if self.service.has(CharacteristicsTypes.FAN_STATE_TARGET): features |= ClimateEntityFeature.FAN_MODE @@ -179,6 +192,12 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): 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 +216,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): 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 +407,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): 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 +429,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): 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 +447,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): {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 +470,12 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): 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 +508,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): 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 +549,8 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): 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 +560,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """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 +572,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """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 +584,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """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 +606,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """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 +680,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): 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 +689,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ) 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 08444555aca..7f0f288400d 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -20,7 +20,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr @@ -79,17 +79,6 @@ def formatted_category(category: Categories) -> str: return str(category.name).replace("_", " ").title() -@callback -def find_existing_config_entry( - hass: HomeAssistant, upper_case_hkid: str -) -> config_entries.ConfigEntry | None: - """Return a set of the configured hosts.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("AccessoryPairingID") == upper_case_hkid: - return entry - return None - - def ensure_pin_format(pin: str, allow_insecure_setup_codes: Any = None) -> str: """Ensure a pin code is correctly formatted. @@ -283,9 +272,13 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably - # invalid. Remove it automatically. - if not paired and ( - existing := find_existing_config_entry(self.hass, upper_case_hkid) + # invalid. Remove it automatically if it has an accessory pairing id + # (which means it was paired with us at some point) and was not + # ignored by the user. + if ( + not paired + and existing_entry + and (accessory_pairing_id := existing_entry.data.get("AccessoryPairingID")) ): if self.controller is None: await self._async_setup_controller() @@ -295,7 +288,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert self.controller pairing = self.controller.load_pairing( - existing.data["AccessoryPairingID"], dict(existing.data) + accessory_pairing_id, dict(existing_entry.data) ) try: @@ -310,7 +303,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): model, hkid, ) - await self.hass.config_entries.async_remove(existing.entry_id) + await self.hass.config_entries.async_remove(existing_entry.entry_id) else: _LOGGER.debug( ( diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index ef806cb52bc..c127c6dd95e 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -261,7 +261,7 @@ class HKDevice: # Ideally we would know which entities we are about to add # so we only poll those chars but that is not possible # yet. - attempts = None if self.hass.state == CoreState.running else 1 + attempts = None if self.hass.state is CoreState.running else 1 if ( transport == Transport.BLE and pairing.accessories @@ -641,7 +641,9 @@ class HKDevice: await self.async_add_new_entities() @callback - def async_entity_key_removed(self, entity_key: tuple[int, int | None, int | None]): + def async_entity_key_removed( + self, entity_key: tuple[int, int | None, int | None] + ) -> None: """Handle an entity being removed. Releases the entity from self.entities so it can be added again. @@ -666,7 +668,7 @@ class HKDevice: self.char_factories.append(add_entities_cb) self._add_new_entities_for_char([add_entities_cb]) - def _add_new_entities_for_char(self, handlers) -> None: + def _add_new_entities_for_char(self, handlers: list[AddCharacteristicCb]) -> None: for accessory in self.entity_map.accessories: for service in accessory.services: for char in service.characteristics: @@ -768,7 +770,7 @@ class HKDevice: """Request an debounced update from the accessory.""" await self._debounced_update.async_call() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" if not self.pollable_characteristics: self.async_update_available_state() diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index f94e1145627..f99563843c7 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 . import KNOWN_DEVICES 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 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverEntity): 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 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): CharacteristicsTypes.OBSTRUCTION_DETECTED, ] - @property + @cached_property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" features = ( diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index fa4c1c171c2..6dc97bf6821 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -115,7 +115,7 @@ class TriggerSource: trigger_callbacks.append(event_handler) - def async_remove_handler(): + def async_remove_handler() -> None: trigger_callbacks.remove(event_handler) return async_remove_handler @@ -215,7 +215,7 @@ async def async_setup_triggers_for_entry( conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback - def async_add_characteristic(service: Service): + def async_add_characteristic(service: Service) -> bool: aid = service.accessory.aid service_type = service.type @@ -257,7 +257,9 @@ def async_get_or_create_trigger_source( return source -def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], dict[str, Any]]): +def async_fire_triggers( + conn: HKDevice, events: dict[tuple[int, int], dict[str, Any]] +) -> None: """Process events generated by a HomeKit accessory into automation triggers.""" trigger_sources: dict[str, TriggerSource] = conn.hass.data.get(TRIGGERS, {}) if not trigger_sources: diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index d1f48a67e7f..496866299d6 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 @@ class HomeKitEntity(Entity): 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.""" @@ -97,7 +109,7 @@ class HomeKitEntity(Entity): self._accessory.async_entity_key_removed(self._entity_key) @callback - def _async_unsubscribe_chars(self): + def _async_unsubscribe_chars(self) -> None: """Handle unsubscribing from characteristics.""" if self._char_subscription: self._char_subscription() @@ -106,7 +118,7 @@ class HomeKitEntity(Entity): self._accessory.remove_watchable_characteristics(self.watchable_characteristics) @callback - def _async_subscribe_chars(self): + def _async_subscribe_chars(self) -> None: """Handle registering characteristics to watch and subscribe.""" self._accessory.add_pollable_characteristics(self.pollable_characteristics) self._accessory.add_watchable_characteristics(self.watchable_characteristics) @@ -149,6 +161,7 @@ class HomeKitEntity(Entity): self.pollable_characteristics = [] self.watchable_characteristics = [] self.all_characteristics = set() + self.all_iids = set() char_types = self.get_characteristic_types() @@ -164,6 +177,7 @@ class HomeKitEntity(Entity): 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 @@ class HomeKitEntity(Entity): @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/event.py b/homeassistant/components/homekit_controller/event.py index 86046415e35..8f3d71682f1 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -72,7 +72,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): ) @callback - def _handle_event(self): + def _handle_event(self) -> None: if self._char.value is None: # For IP backed devices the characteristic is marked as # pollable, but always returns None when polled diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 550f86ddbe4..d87b6ab3e39 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 . import KNOWN_DEVICES 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 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): """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 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): 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 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): return features - @property + @cached_property def speed_count(self) -> int: """Speed count for the fan.""" return round( @@ -157,7 +177,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): 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 57e4e7e73d8..b5e67e7f1a4 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 . import KNOWN_DEVICES 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 @@ HA_MODE_TO_HK = { } -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 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): 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) - return available_modes + @cached_property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return int(self.service[self._humidity_char].minValue or DEFAULT_MIN_HUMIDITY) + + @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,37 +119,33 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): 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 - ) + 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, + ] -class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): +class HomeKitHumidifier(HomeKitBaseHumidifier): + """Representation of a HomeKit Controller Humidifier.""" + + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + + +class HomeKitDehumidifier(HomeKitBaseHumidifier): """Representation of a HomeKit Controller Humidifier.""" _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER - _attr_supported_features = HumidifierEntityFeature.MODES + _humidity_char = CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + _on_mode_value = 2 def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise the dehumidifier.""" @@ -160,106 +154,10 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): 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, - 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}) - - 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( + return super().get_characteristic_types() + [ 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. - - Requires HumidifierEntityFeature.MODES. - """ - mode = self.service.value( - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE - ) - return MODE_AUTO if mode == 1 else MODE_NORMAL - - @property - def available_modes(self) -> list[str] | None: - """Return a list of available modes. - - Requires HumidifierEntityFeature.MODES. - """ - available_modes = [ - MODE_NORMAL, - MODE_AUTO, ] - 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 5bf810a89db..fd3bf4f800b 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 @@ -17,11 +17,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.color as color_util from . import KNOWN_DEVICES 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 +56,14 @@ async def async_setup_entry( 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,15 +92,19 @@ class HomeKitLight(HomeKitEntity, LightEntity): self.service.value(CharacteristicsTypes.SATURATION), ) - @property + @cached_property def min_mireds(self) -> int: """Return minimum supported color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().min_mireds min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue return int(min_value) if min_value else super().min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the maximum color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().max_mireds max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue return int(max_value) if max_value else super().max_mireds @@ -113,7 +131,7 @@ class HomeKitLight(HomeKitEntity, LightEntity): return ColorMode.ONOFF - @property + @cached_property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" color_modes: set[ColorMode] = set() @@ -122,8 +140,9 @@ class HomeKitLight(HomeKitEntity, LightEntity): CharacteristicsTypes.SATURATION ): color_modes.add(ColorMode.HS) + color_modes.add(ColorMode.COLOR_TEMP) - if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + elif self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) if not color_modes and self.service.has(CharacteristicsTypes.BRIGHTNESS): @@ -140,23 +159,36 @@ class HomeKitLight(HomeKitEntity, LightEntity): temperature = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - characteristics = {} - - if hs_color is not None: - characteristics.update( - { - CharacteristicsTypes.HUE: hs_color[0], - CharacteristicsTypes.SATURATION: hs_color[1], - } - ) + characteristics: dict[str, Any] = {} if brightness is not None: characteristics[CharacteristicsTypes.BRIGHTNESS] = int( brightness * 100 / 255 ) + # If they send both temperature and hs_color, and the device + # does not support both, temperature will win. This is not + # expected to happen in the UI, but it is possible via a manual + # service call. if temperature is not None: - characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature) + if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int( + temperature + ) + elif hs_color is None: + # Some HomeKit devices implement color temperature with HS + # since the spec "technically" does not permit the COLOR_TEMPERATURE + # characteristic and the HUE and SATURATION characteristics to be + # present at the same time. + hue_sat = color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(temperature) + ) + characteristics[CharacteristicsTypes.HUE] = hue_sat[0] + characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1] + + if hs_color is not None: + characteristics[CharacteristicsTypes.HUE] = hs_color[0] + characteristics[CharacteristicsTypes.SATURATION] = hs_color[1] characteristics[CharacteristicsTypes.ON] = True diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 799058b0e20..1617b907a26 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.1.3"], + "requirements": ["aiohomekit==3.1.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index eb5b99e126d..ebfba110e48 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -164,7 +164,7 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR, name="Energy kWh", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: HomeKitSensorEntityDescription( diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index c1dead1835e..76d9dff4d46 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -56,9 +56,13 @@ class HMThermostat(HMDevice, ClimateEntity): """Representation of a Homematic thermostat.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 09d00e9bee1..63b78e91a2f 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -70,6 +70,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 1680904bbca..4647e553382 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -1,17 +1,25 @@ """Helper functions for Homematicip Cloud Integration.""" +from __future__ import annotations +from collections.abc import Callable, Coroutine from functools import wraps import json import logging +from typing import Any, Concatenate, ParamSpec, TypeGuard, TypeVar from homeassistant.exceptions import HomeAssistantError from . import HomematicipGenericEntity +_HomematicipGenericEntityT = TypeVar( + "_HomematicipGenericEntityT", bound=HomematicipGenericEntity +) +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) -def is_error_response(response) -> bool: +def is_error_response(response: Any) -> TypeGuard[dict[str, Any]]: """Response from async call contains errors or not.""" if isinstance(response, dict): return response.get("errorCode") not in ("", None) @@ -19,13 +27,19 @@ def is_error_response(response) -> bool: return False -def handle_errors(func): +def handle_errors( + func: Callable[ + Concatenate[_HomematicipGenericEntityT, _P], Coroutine[Any, Any, Any] + ], +) -> Callable[Concatenate[_HomematicipGenericEntityT, _P], Coroutine[Any, Any, Any]]: """Handle async errors.""" @wraps(func) - async def inner(self: HomematicipGenericEntity) -> None: + async def inner( + self: _HomematicipGenericEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: """Handle errors from async call.""" - result = await func(self) + result = await func(self, *args, **kwargs) if is_error_response(result): _LOGGER.error( "Error while execute function %s: %s", diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 09457ce0792..38ce6de7caf 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -110,7 +110,7 @@ SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( async def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" - if hass.services.async_services().get(HMIPC_DOMAIN): + if hass.services.async_services_for_domain(HMIPC_DOMAIN): return @verify_domain_control(hass, HMIPC_DOMAIN) diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json new file mode 100644 index 00000000000..e6b1a34841f --- /dev/null +++ b/homeassistant/components/homewizard/icons.json @@ -0,0 +1,76 @@ +{ + "entity": { + "number": { + "status_light_brightness": { + "default": "mdi:lightbulb-on" + } + }, + "sensor": { + "active_liter_lpm": { + "default": "mdi:water" + }, + "active_tariff": { + "default": "mdi:calendar-clock" + }, + "any_power_fail_count": { + "default": "mdi:transmission-tower-off" + }, + "dsmr_version": { + "default": "mdi:counter" + }, + "gas_unique_id": { + "default": "mdi:alphabetical-variant" + }, + "long_power_fail_count": { + "default": "mdi:transmission-tower-off" + }, + "meter_model": { + "default": "mdi:gauge" + }, + "total_liter_m3": { + "default": "mdi:gauge" + }, + "unique_meter_id": { + "default": "mdi:alphabetical-variant" + }, + "voltage_sag_l1_count": { + "default": "mdi:alert" + }, + "voltage_sag_l2_count": { + "default": "mdi:alert" + }, + "voltage_sag_l3_count": { + "default": "mdi:alert" + }, + "voltage_swell_l1_count": { + "default": "mdi:alert" + }, + "voltage_swell_l2_count": { + "default": "mdi:alert" + }, + "voltage_swell_l3_count": { + "default": "mdi:alert" + }, + "wifi_ssid": { + "default": "mdi:wifi" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + }, + "switch": { + "cloud_connection": { + "default": "mdi:cloud", + "state": { + "off": "mdi:cloud-off-outline" + } + }, + "switch_lock": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + } + } + } +} diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 949dda2a8aa..2db140d5fe9 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==4.1.0"], + "requirements": ["python-homewizard-energy==4.3.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index ced870d7072..6145db125a1 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -29,7 +29,6 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): """Representation of status light number.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:lightbulb-on" _attr_translation_key = "status_light_brightness" _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 12655dbbc39..e544ee601c0 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -5,9 +5,10 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.models import Data +from homewizard_energy.models import Data, ExternalDevice from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -15,8 +16,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_VIA_DEVICE, PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -25,6 +30,8 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -44,11 +51,23 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Data], StateType] +@dataclass(frozen=True, kw_only=True) +class HomeWizardExternalSensorEntityDescription(SensorEntityDescription): + """Class describing HomeWizard sensor entities.""" + + suggested_device_class: SensorDeviceClass + device_name: str + + +def to_percentage(value: float | None) -> float | None: + """Convert 0..1 value to percentage when value is not None.""" + return value * 100 if value is not None else None + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", translation_key="dsmr_version", - icon="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.smr_version is not None, value_fn=lambda data: data.smr_version, @@ -56,7 +75,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.meter_model is not None, value_fn=lambda data: data.meter_model, @@ -64,7 +82,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", - icon="mdi:alphabetical-variant", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.unique_meter_id is not None, value_fn=lambda data: data.unique_meter_id, @@ -72,7 +89,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.wifi_ssid is not None, value_fn=lambda data: data.wifi_ssid, @@ -80,7 +96,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - icon="mdi:calendar-clock", has_fn=lambda data: data.active_tariff is not None, value_fn=lambda data: ( None if data.active_tariff is None else str(data.active_tariff) @@ -91,7 +106,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="wifi_strength", translation_key="wifi_strength", - icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -110,7 +124,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", - translation_key="total_energy_import_t1_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -123,7 +138,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", - translation_key="total_energy_import_t2_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -132,7 +148,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", - translation_key="total_energy_import_t3_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "3"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -141,7 +158,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", - translation_key="total_energy_import_t4_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "4"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -160,7 +178,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", - translation_key="total_energy_export_t1_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -174,7 +193,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", - translation_key="total_energy_export_t2_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -184,7 +204,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", - translation_key="total_energy_export_t3_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "3"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -194,7 +215,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", - translation_key="total_energy_export_t4_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "4"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -204,7 +226,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_w", - translation_key="active_power_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -214,7 +235,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l1_w", - translation_key="active_power_l1_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -224,7 +246,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l2_w", - translation_key="active_power_l2_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -234,7 +257,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l3_w", - translation_key="active_power_l3_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -242,9 +266,19 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_power_l3_w is not None, value_fn=lambda data: data.active_power_l3_w, ), + HomeWizardSensorEntityDescription( + key="active_voltage_v", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_voltage_v is not None, + value_fn=lambda data: data.active_voltage_v, + ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", - translation_key="active_voltage_l1_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -254,7 +288,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", - translation_key="active_voltage_l2_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -264,7 +299,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", - translation_key="active_voltage_l3_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -272,9 +308,19 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_voltage_l3_v is not None, value_fn=lambda data: data.active_voltage_l3_v, ), + HomeWizardSensorEntityDescription( + key="active_current_a", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_current_a is not None, + value_fn=lambda data: data.active_current_a, + ), HomeWizardSensorEntityDescription( key="active_current_l1_a", - translation_key="active_current_l1_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -284,7 +330,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_current_l2_a", - translation_key="active_current_l2_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -294,7 +341,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_current_l3_a", - translation_key="active_current_l3_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -304,7 +352,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_frequency_hz", - translation_key="active_frequency_hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -312,50 +359,176 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_frequency_hz is not None, value_fn=lambda data: data.active_frequency_hz, ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_va", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_va is not None, + value_fn=lambda data: data.active_apparent_power_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l1_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l1_va is not None, + value_fn=lambda data: data.active_apparent_power_l1_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l2_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l2_va is not None, + value_fn=lambda data: data.active_apparent_power_l2_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l3_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l3_va is not None, + value_fn=lambda data: data.active_apparent_power_l3_va, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_var", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_var is not None, + value_fn=lambda data: data.active_reactive_power_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l1_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l1_var is not None, + value_fn=lambda data: data.active_reactive_power_l1_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l2_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l2_var is not None, + value_fn=lambda data: data.active_reactive_power_l2_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l3_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l3_var is not None, + value_fn=lambda data: data.active_reactive_power_l3_var, + ), + HomeWizardSensorEntityDescription( + key="active_power_factor", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor is not None, + value_fn=lambda data: to_percentage(data.active_power_factor), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l1", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l1), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l2", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l2), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l3", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l3), + ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", - translation_key="voltage_sag_l1_count", - icon="mdi:alert", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l1_count is not None, value_fn=lambda data: data.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", - translation_key="voltage_sag_l2_count", - icon="mdi:alert", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l2_count is not None, value_fn=lambda data: data.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", - translation_key="voltage_sag_l3_count", - icon="mdi:alert", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l3_count is not None, value_fn=lambda data: data.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", - translation_key="voltage_swell_l1_count", - icon="mdi:alert", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l1_count is not None, value_fn=lambda data: data.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", - translation_key="voltage_swell_l2_count", - icon="mdi:alert", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l2_count is not None, value_fn=lambda data: data.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", - translation_key="voltage_swell_l3_count", - icon="mdi:alert", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l3_count is not None, value_fn=lambda data: data.voltage_swell_l3_count, @@ -363,7 +536,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", - icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.any_power_fail_count is not None, value_fn=lambda data: data.any_power_fail_count, @@ -371,7 +543,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", - icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.long_power_fail_count is not None, value_fn=lambda data: data.long_power_fail_count, @@ -392,28 +563,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.monthly_power_peak_w is not None, value_fn=lambda data: data.monthly_power_peak_w, ), - HomeWizardSensorEntityDescription( - key="total_gas_m3", - translation_key="total_gas_m3", - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - 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, - ), - HomeWizardSensorEntityDescription( - key="gas_unique_id", - translation_key="gas_unique_id", - icon="mdi:alphabetical-variant", - entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.gas_unique_id is not None, - value_fn=lambda data: data.gas_unique_id, - ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement="l/min", - icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.active_liter_lpm is not None, value_fn=lambda data: data.active_liter_lpm, @@ -422,7 +575,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="total_liter_m3", translation_key="total_liter_m3", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - icon="mdi:gauge", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_liter_m3 is not None, @@ -431,16 +583,81 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ) +EXTERNAL_SENSORS = { + ExternalDevice.DeviceType.GAS_METER: HomeWizardExternalSensorEntityDescription( + key="gas_meter", + suggested_device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Gas meter", + ), + ExternalDevice.DeviceType.HEAT_METER: HomeWizardExternalSensorEntityDescription( + key="heat_meter", + suggested_device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Heat meter", + ), + ExternalDevice.DeviceType.WARM_WATER_METER: HomeWizardExternalSensorEntityDescription( + key="warm_water_meter", + suggested_device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Warm water meter", + ), + ExternalDevice.DeviceType.WATER_METER: HomeWizardExternalSensorEntityDescription( + key="water_meter", + suggested_device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Water meter", + ), + ExternalDevice.DeviceType.INLET_HEAT_METER: HomeWizardExternalSensorEntityDescription( + key="inlet_heat_meter", + suggested_device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Inlet heat meter", + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + + # Migrate original gas meter sensor to ExternalDevice + ent_reg = er.async_get(hass) + + if ( + entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{entry.unique_id}_total_gas_m3" + ) + ) and coordinator.data.data.gas_unique_id is not None: + ent_reg.async_update_entity( + entity_id, + new_unique_id=f"{DOMAIN}_{coordinator.data.data.gas_unique_id}", + ) + + # Remove old gas_unique_id sensor + if entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{entry.unique_id}_gas_unique_id" + ): + ent_reg.async_remove(entity_id) + + # Initialize default sensors + entities: list = [ HomeWizardSensorEntity(coordinator, description) for description in SENSORS if description.has_fn(coordinator.data.data) - ) + ] + + # Initialize external devices + if coordinator.data.data.external_devices is not None: + for unique_id, device in coordinator.data.data.external_devices.items(): + if description := EXTERNAL_SENSORS.get(device.meter_type): + entities.append( + HomeWizardExternalSensorEntity(coordinator, description, unique_id) + ) + + async_add_entities(entities) class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): @@ -469,3 +686,74 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): def available(self) -> bool: """Return availability of meter.""" return super().available and self.native_value is not None + + +class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity): + """Representation of externally connected HomeWizard Sensor.""" + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + description: HomeWizardExternalSensorEntityDescription, + device_unique_id: str, + ) -> None: + """Initialize Externally connected HomeWizard Sensors.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_unique_id + self._suggested_device_class = description.suggested_device_class + self._attr_unique_id = f"{DOMAIN}_{device_unique_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_unique_id)}, + name=description.device_name, + manufacturer="HomeWizard", + model=coordinator.data.device.product_type, + serial_number=device_unique_id, + ) + if coordinator.data.device.serial is not None: + self._attr_device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + coordinator.data.device.serial, + ) + + @property + def native_value(self) -> float | int | str | None: + """Return the sensor value.""" + return self.device.value if self.device is not None else None + + @property + def device(self) -> ExternalDevice | None: + """Return ExternalDevice object.""" + return ( + self.coordinator.data.data.external_devices[self._device_id] + if self.coordinator.data.data.external_devices is not None + else None + ) + + @property + def available(self) -> bool: + """Return availability of meter.""" + return super().available and self.device is not None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return unit of measurement based on device unit.""" + if (device := self.device) is None: + return None + + # API returns 'm3' but we expect m³ + if device.unit == "m3": + return UnitOfVolume.CUBIC_METERS + + return device.unit + + @property + def device_class(self) -> SensorDeviceClass | None: + """Validate unit of measurement and set device class.""" + if ( + self.native_unit_of_measurement + not in DEVICE_CLASS_UNITS[self._suggested_device_class] + ): + return None + + return self._suggested_device_class diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index acdb321d6ff..ca903330a44 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -48,91 +48,46 @@ "name": "Wi-Fi SSID" }, "active_tariff": { - "name": "Active tariff" + "name": "Tariff" }, "wifi_strength": { "name": "Wi-Fi strength" }, "total_energy_import_kwh": { - "name": "Total energy import" + "name": "Energy import" }, - "total_energy_import_t1_kwh": { - "name": "Total energy import tariff 1" - }, - "total_energy_import_t2_kwh": { - "name": "Total energy import tariff 2" - }, - "total_energy_import_t3_kwh": { - "name": "Total energy import tariff 3" - }, - "total_energy_import_t4_kwh": { - "name": "Total energy import tariff 4" + "total_energy_import_tariff_kwh": { + "name": "Energy import tariff {tariff}" }, "total_energy_export_kwh": { - "name": "Total energy export" + "name": "Energy export" }, - "total_energy_export_t1_kwh": { - "name": "Total energy export tariff 1" + "total_energy_export_tariff_kwh": { + "name": "Energy export tariff {tariff}" }, - "total_energy_export_t2_kwh": { - "name": "Total energy export tariff 2" + "active_power_phase_w": { + "name": "Power phase {phase}" }, - "total_energy_export_t3_kwh": { - "name": "Total energy export tariff 3" + "active_voltage_phase_v": { + "name": "Voltage phase {phase}" }, - "total_energy_export_t4_kwh": { - "name": "Total energy export tariff 4" + "active_current_phase_a": { + "name": "Current phase {phase}" }, - "active_power_w": { - "name": "Active power" + "active_apparent_power_phase_va": { + "name": "Apparent power phase {phase}" }, - "active_power_l1_w": { - "name": "Active power phase 1" + "active_reactive_power_phase_var": { + "name": "Reactive power phase {phase}" }, - "active_power_l2_w": { - "name": "Active power phase 2" + "active_power_factor_phase": { + "name": "Power factor phase {phase}" }, - "active_power_l3_w": { - "name": "Active power phase 3" + "voltage_sag_phase_count": { + "name": "Voltage sags detected phase {phase}" }, - "active_voltage_l1_v": { - "name": "Active voltage phase 1" - }, - "active_voltage_l2_v": { - "name": "Active voltage phase 2" - }, - "active_voltage_l3_v": { - "name": "Active voltage phase 3" - }, - "active_current_l1_a": { - "name": "Active current phase 1" - }, - "active_current_l2_a": { - "name": "Active current phase 2" - }, - "active_current_l3_a": { - "name": "Active current phase 3" - }, - "active_frequency_hz": { - "name": "Active frequency" - }, - "voltage_sag_l1_count": { - "name": "Voltage sags detected phase 1" - }, - "voltage_sag_l2_count": { - "name": "Voltage sags detected phase 2" - }, - "voltage_sag_l3_count": { - "name": "Voltage sags detected phase 3" - }, - "voltage_swell_l1_count": { - "name": "Voltage swells detected phase 1" - }, - "voltage_swell_l2_count": { - "name": "Voltage swells detected phase 2" - }, - "voltage_swell_l3_count": { - "name": "Voltage swells detected phase 3" + "voltage_swell_phase_count": { + "name": "Voltage swells detected phase {phase}" }, "any_power_fail_count": { "name": "Power failures detected" @@ -141,19 +96,13 @@ "name": "Long power failures detected" }, "active_power_average_w": { - "name": "Active average demand" + "name": "Average demand" }, "monthly_power_peak_w": { "name": "Peak demand current month" }, - "total_gas_m3": { - "name": "Total gas" - }, - "gas_unique_id": { - "name": "Gas meter identifier" - }, "active_liter_lpm": { - "name": "Active water usage" + "name": "Water usage" }, "total_liter_m3": { "name": "Total water usage" diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index fea4d7018bf..72e0f43a2cf 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -29,7 +29,6 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): 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]] @@ -48,8 +47,6 @@ SWITCHES = [ key="switch_lock", translation_key="switch_lock", entity_category=EntityCategory.CONFIG, - icon="mdi:lock", - icon_off="mdi:lock-open", create_fn=lambda coordinator: coordinator.supports_state(), available_fn=lambda data: data.state is not None, is_on_fn=lambda data: data.state.switch_lock if data.state else None, @@ -59,8 +56,6 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - icon="mdi:cloud", - icon_off="mdi:cloud-off-outline", create_fn=lambda coordinator: coordinator.supports_system(), available_fn=lambda data: data.system is not None, is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, @@ -99,13 +94,6 @@ class HomeWizardSwitchEntity(HomeWizardEntity, SwitchEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - @property - def icon(self) -> str | None: - """Return the icon.""" - if self.entity_description.icon_off and self.is_on is False: - return self.entity_description.icon_off - return super().icon - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index f5cce1d890a..baabf4ca4d8 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -8,14 +8,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from .const import ( _LOGGER, CONF_COOL_AWAY_TEMPERATURE, - CONF_DEV_ID, CONF_HEAT_AWAY_TEMPERATURE, - CONF_LOC_ID, DOMAIN, ) @@ -50,9 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - client = aiosomecomfort.AIOSomeComfort( - username, password, session=async_get_clientsession(hass) - ) + if len(hass.config_entries.async_entries(DOMAIN)) > 1: + session = async_create_clientsession(hass) + else: + session = async_get_clientsession(hass) + + client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() await client.discover() @@ -70,23 +74,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "Failed to initialize the Honeywell client: Connection error" ) from ex - loc_id = config_entry.data.get(CONF_LOC_ID) - dev_id = config_entry.data.get(CONF_DEV_ID) - devices = {} for location in client.locations_by_id.values(): - if not loc_id or location.locationid == loc_id: - for device in location.devices_by_id.values(): - if not dev_id or device.deviceid == dev_id: - devices[device.deviceid] = device + for device in location.devices_by_id.values(): + devices[device.deviceid] = device if len(devices) == 0: _LOGGER.debug("No devices found") return False - data = HoneywellData(config_entry.entry_id, client, devices) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = data + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) @@ -105,7 +102,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) if unload_ok: - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 2f06dd1cfbe..efd06ba2905 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -51,7 +51,7 @@ ATTR_FAN_ACTION = "fan_action" ATTR_PERMANENT_HOLD = "permanent_hold" -PRESET_HOLD = "Hold" +PRESET_HOLD = "hold" HEATING_MODES = {"heat", "emheat", "auto"} COOLING_MODES = {"cool", "auto"} @@ -142,6 +142,8 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = "honeywell" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -186,6 +188,10 @@ class HoneywellUSThermostat(ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if device._data.get("canControlHumidification"): self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY @@ -303,7 +309,7 @@ class HoneywellUSThermostat(ClimateEntity): if self._is_permanent_hold(): return PRESET_HOLD - return None + return PRESET_NONE @property def is_aux_heat(self) -> bool | None: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index dab8353c773..43d08ee2294 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -66,15 +66,13 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.entry, data={ **self.entry.data, **user_input, }, ) - 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", diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index 32846563c44..28868812e24 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -7,7 +7,5 @@ CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" DEFAULT_COOL_AWAY_TEMPERATURE = 88 DEFAULT_HEAT_AWAY_TEMPERATURE = 61 -CONF_DEV_ID = "thermostat" -CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) RETRY = 3 diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index c4ddba49357..d0f0c8281f7 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.24"] + "requirements": ["AIOSomecomfort==0.0.25"] } diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index b0cd2a52c1b..6f855828e01 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -40,6 +40,19 @@ "outdoor_humidity": { "name": "Outdoor humidity" } + }, + "climate": { + "honeywell": { + "state_attributes": { + "preset_mode": { + "state": { + "hold": "Hold", + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" + } + } + } + } } } } diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 618bab91f7f..99d38bf582e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -135,7 +135,8 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: hass.data[STORAGE_KEY] = refresh_token.id - async def async_validate_auth_header(request: Request) -> bool: + @callback + def async_validate_auth_header(request: Request) -> bool: """Test authorization header against access token. Basic auth_type is legacy code, should be removed with api_password. @@ -151,7 +152,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if auth_type != "Bearer": return False - refresh_token = await hass.auth.async_validate_access_token(auth_val) + refresh_token = hass.auth.async_validate_access_token(auth_val) if refresh_token is None: return False @@ -163,7 +164,8 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True - async def async_validate_signed_request(request: Request) -> bool: + @callback + def async_validate_signed_request(request: Request) -> bool: """Validate a signed request.""" if (secret := hass.data.get(DATA_SIGN_SECRET)) is None: return False @@ -189,7 +191,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["params"] != params: return False - refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) + refresh_token = hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: return False @@ -205,7 +207,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: """Authenticate as middleware.""" authenticated = False - if hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( + if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( request ): authenticated = True @@ -216,7 +218,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: elif ( request.method == "GET" and SIGN_QUERY_PARAM in request.query_string - and await async_validate_signed_request(request) + and async_validate_signed_request(request) ): authenticated = True auth_type = "signed request" diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 4d8ac5c2df5..b2e8e535fd2 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -5,16 +5,18 @@ from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar, overload -from aiohttp.web import Request, Response +from aiohttp.web import Request, Response, StreamResponse +from homeassistant.auth.models import User from homeassistant.exceptions import Unauthorized from .view import HomeAssistantView _HomeAssistantViewT = TypeVar("_HomeAssistantViewT", bound=HomeAssistantView) +_ResponseT = TypeVar("_ResponseT", bound=Response | StreamResponse) _P = ParamSpec("_P") _FuncType = Callable[ - Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, Response] + Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, _ResponseT] ] @@ -23,30 +25,36 @@ def require_admin( _func: None = None, *, error: Unauthorized | None = None, -) -> Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]]: +) -> Callable[ + [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], + _FuncType[_HomeAssistantViewT, _P, _ResponseT], +]: ... @overload def require_admin( - _func: _FuncType[_HomeAssistantViewT, _P], -) -> _FuncType[_HomeAssistantViewT, _P]: + _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT], +) -> _FuncType[_HomeAssistantViewT, _P, _ResponseT]: ... def require_admin( - _func: _FuncType[_HomeAssistantViewT, _P] | None = None, + _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT] | None = None, *, error: Unauthorized | None = None, ) -> ( - Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]] - | _FuncType[_HomeAssistantViewT, _P] + Callable[ + [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], + _FuncType[_HomeAssistantViewT, _P, _ResponseT], + ] + | _FuncType[_HomeAssistantViewT, _P, _ResponseT] ): """Home Assistant API decorator to require user to be an admin.""" def decorator_require_admin( - func: _FuncType[_HomeAssistantViewT, _P], - ) -> _FuncType[_HomeAssistantViewT, _P]: + func: _FuncType[_HomeAssistantViewT, _P, _ResponseT], + ) -> _FuncType[_HomeAssistantViewT, _P, _ResponseT]: """Wrap the provided with_admin function.""" @wraps(func) @@ -55,9 +63,10 @@ def require_admin( request: Request, *args: _P.args, **kwargs: _P.kwargs, - ) -> Response: + ) -> _ResponseT: """Check admin and call function.""" - if not request["hass_user"].is_admin: + user: User = request["hass_user"] + if not user.is_admin: raise error or Unauthorized() return await func(self, request, *args, **kwargs) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 399cbf70ad7..647b7e42a3a 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3" + "aiohttp-zlib-ng==0.3.1" ] } diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index e8e3aa4699c..4d71334f1cf 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from functools import lru_cache import logging import re from typing import Final @@ -43,6 +44,7 @@ UNSAFE_URL_BYTES = ["\t", "\r", "\n"] def setup_security_filter(app: Application) -> None: """Create security filter middleware for the app.""" + @lru_cache def _recursive_unquote(value: str) -> str: """Handle values that are encoded multiple times.""" if (unquoted := unquote(value)) != value: @@ -54,34 +56,38 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + path_with_query_string = f"{request.path}?{request.query_string}" + for unsafe_byte in UNSAFE_URL_BYTES: - if unsafe_byte in request.path: + if unsafe_byte in path_with_query_string: + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest _LOGGER.warning( "Filtered a request with an unsafe byte in path: %s", request.raw_path, ) raise HTTPBadRequest - if unsafe_byte in request.query_string: + if FILTERS.search(_recursive_unquote(path_with_query_string)): + # Check the full path with query string first, if its + # a hit, than check just the query string to give a more + # specific warning. + if FILTERS.search(_recursive_unquote(request.query_string)): _LOGGER.warning( - "Filtered a request with unsafe byte query string: %s", + "Filtered a request with a potential harmful query string: %s", request.raw_path, ) raise HTTPBadRequest - if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path ) raise HTTPBadRequest - if FILTERS.search(_recursive_unquote(request.query_string)): - _LOGGER.warning( - "Filtered a request with a potential harmful query string: %s", - request.raw_path, - ) - raise HTTPBadRequest - return await handler(request) app.middlewares.append(security_filter_middleware) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 7fe359d6486..e6e773d4c0c 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -19,10 +19,10 @@ from .const import KEY_HASS 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: LRU[tuple[str, Path, bool], tuple[Path | None, str | None]] = LRU(512) +PATH_CACHE: LRU[tuple[str, Path], tuple[Path | None, str | None]] = LRU(512) -def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: +def _get_file_path(rel_url: str, directory: Path) -> Path | None: """Return the path to file on disk or None.""" filename = Path(rel_url) if filename.anchor: @@ -31,8 +31,7 @@ def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path # where the static dir is totally different raise HTTPForbidden filepath: Path = directory.joinpath(filename).resolve() - if not follow_symlinks: - filepath.relative_to(directory) + filepath.relative_to(directory) # on opening a dir, load its contents if allowed if filepath.is_dir(): return None @@ -47,7 +46,7 @@ class CachingStaticResource(StaticResource): async def _handle(self, request: Request) -> StreamResponse: """Return requested file from disk as a FileResponse.""" rel_url = request.match_info["filename"] - key = (rel_url, self._directory, self._follow_symlinks) + key = (rel_url, self._directory) if (filepath_content_type := PATH_CACHE.get(key)) is None: hass: HomeAssistant = request.app[KEY_HASS] try: diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 42a1e066ac7..29c59d3ff9c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -13,7 +13,6 @@ from xml.parsers.expat import ExpatError from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection -from huawei_lte_api.enums.device import ControlModeEnum from huawei_lte_api.exceptions import ( LoginErrorInvalidCredentialsException, ResponseErrorException, @@ -51,7 +50,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -59,8 +57,6 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, ATTR_CONFIG_ENTRY_ID, - BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, - BUTTON_KEY_RESTART, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, @@ -84,8 +80,6 @@ from .const import ( KEY_WLAN_WIFI_FEATURE_SWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, NOTIFY_SUPPRESS_TIMEOUT, - SERVICE_CLEAR_TRAFFIC_STATISTICS, - SERVICE_REBOOT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, @@ -133,9 +127,9 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, - Platform.SELECT, ] @@ -533,45 +527,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("%s: router %s unavailable", service.service, url) 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 - result = router.client.device.set_control(ControlModeEnum.REBOOT) - _LOGGER.debug("%s: %s", service.service, result) - elif service.service == SERVICE_RESUME_INTEGRATION: + if service.service == SERVICE_RESUME_INTEGRATION: # Login will be handled automatically on demand router.suspended = False _LOGGER.debug("%s: %s", service.service, "done") diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index eba0f3ce90b..af9bfd330e9 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -19,14 +19,10 @@ UPDATE_SIGNAL = f"{DOMAIN}_update" CONNECTION_TIMEOUT = 10 NOTIFY_SUPPRESS_TIMEOUT = 30 -SERVICE_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" -SERVICE_REBOOT = "reboot" SERVICE_RESUME_INTEGRATION = "resume_integration" SERVICE_SUSPEND_INTEGRATION = "suspend_integration" ADMIN_SERVICES = { - SERVICE_CLEAR_TRAFFIC_STATISTICS, - SERVICE_REBOOT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, } diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml index 9d0cf5d91e6..a90b0b1df74 100644 --- a/homeassistant/components/huawei_lte/services.yaml +++ b/homeassistant/components/huawei_lte/services.yaml @@ -1,17 +1,3 @@ -clear_traffic_statistics: - fields: - url: - example: http://192.168.100.1/ - selector: - text: - -reboot: - fields: - url: - example: http://192.168.100.1/ - selector: - text: - resume_integration: fields: url: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 225146799a3..a1a3f5c9416 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -309,33 +309,7 @@ } } }, - "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", - "description": "Clears traffic statistics.", - "fields": { - "url": { - "name": "[%key:common::config_flow::data::url%]", - "description": "URL of router to clear; optional when only one is configured." - } - } - }, - "reboot": { - "name": "Reboot", - "description": "Reboots router.", - "fields": { - "url": { - "name": "[%key:common::config_flow::data::url%]", - "description": "URL of router to reboot; optional when only one is configured." - } - } - }, "resume_integration": { "name": "Resume integration", "description": "Resumes suspended integration.", diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index da59515e7be..183d2bfb3ae 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -86,12 +86,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): ): event_types.append(event_type.value) self._attr_event_types = event_types - - @property - def name(self) -> str: - """Return name for the entity.""" - # this can be translated too as soon as we support arguments into translations ? - return f"Button {self.resource.metadata.control_id}" + self._attr_translation_placeholders = { + "button_id": self.resource.metadata.control_id + } @callback def _handle_event(self, event_type: EventType, resource: Button) -> None: diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 114f501d7a3..ab1d0fb58ad 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -76,6 +76,7 @@ "entity": { "event": { "button": { + "name": "Button {button_id}", "state_attributes": { "event_type": { "state": { diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 82cf51d3b26..2c1d2ffde68 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 65a8cd9d1d0..37f9d49f0dd 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -86,7 +86,7 @@ DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] @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. diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json new file mode 100644 index 00000000000..2c67f759195 --- /dev/null +++ b/homeassistant/components/humidifier/icons.json @@ -0,0 +1,42 @@ +{ + "entity_component": { + "_": { + "default": "mdi:air-humidifier", + "state": { + "off": "mdi:air-humidifier-off" + }, + "state_attributes": { + "action": { + "default": "mdi:circle-medium", + "state": { + "drying": "mdi:arrow-down-bold", + "humidifying": "mdi:arrow-up-bold", + "idle": "mdi:clock-outline", + "off": "mdi:power" + } + }, + "mode": { + "default": "mdi:circle-medium", + "state": { + "auto": "mdi:refresh-auto", + "away": "mdi:account-arrow-right", + "baby": "mdi:baby-carriage", + "boost": "mdi:rocket-launch", + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "home": "mdi:home", + "normal": "mdi:water-percent", + "sleep": "mdi:power-sleep" + } + } + } + } + }, + "services": { + "set_humidity": "mdi:water-percent", + "set_mode": "mdi:air-humidifier", + "toggle": "mdi:air-humidifier", + "turn_off": "mdi:air-humidifier-off", + "turn_on": "mdi:air-humidifier" + } +} diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index b0e9a29cacc..be4f1afbeb9 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/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 8c6d0fc4dd3..81532187bbf 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging +from typing import Any from aiopvapi.helpers.aiorequest import AioRequest import voluptuous as vol @@ -51,18 +52,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the powerview config flow.""" - self.powerview_config = {} - self.discovered_ip = None - self.discovered_name = None + self.powerview_config: dict[str, str] = {} + self.discovered_ip: str | None = None + self.discovered_name: str | None = 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 the initial step.""" - errors = {} + errors: dict[str, Any] = {} if user_input is not None: info, error = await self._async_validate_or_error(user_input[CONF_HOST]) - if not error: + if info and not error: await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} @@ -73,7 +76,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def _async_validate_or_error(self, host): + async def _async_validate_or_error( + self, host: str + ) -> tuple[dict[str, str], None] | tuple[None, str]: self._async_abort_entries_match({CONF_HOST: host}) try: @@ -110,21 +115,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.discovered_name = name return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self): + async def async_step_discovery_confirm(self) -> FlowResult: """Confirm dhcp or homekit discovery.""" # If we already have the host configured do # not open connections to it if we can avoid it. + assert self.discovered_ip and self.discovered_name self.context[CONF_HOST] = self.discovered_ip for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self.discovered_ip: return self.async_abort(reason="already_in_progress") self._async_abort_entries_match({CONF_HOST: self.discovered_ip}) - info, error = await self._async_validate_or_error(self.discovered_ip) if error: return self.async_abort(reason=error) + assert info is not None await self.async_set_unique_id(info["unique_id"], raise_on_progress=False) self._abort_if_unique_id_configured({CONF_HOST: self.discovered_ip}) @@ -134,7 +140,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with Powerview.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 18fe1cd0a69..6d050bc1dbd 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import logging from math import ceil from typing import Any @@ -137,7 +137,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP - self._forced_resync = None + self._forced_resync: Callable[[], None] | None = None @property def assumed_state(self) -> bool: @@ -291,7 +291,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._async_complete_schedule_update, ) - async def _async_complete_schedule_update(self, _): + async def _async_complete_schedule_update(self, _: datetime) -> None: """Update status of the cover.""" _LOGGER.debug("Processing scheduled update for %s", self.name) self._scheduled_transition_update = None @@ -382,7 +382,7 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): return hd_position_to_hass(self.positions.vane, self._max_tilt) @property - def transition_steps(self): + def transition_steps(self) -> int: """Return the steps to make a move.""" return hd_position_to_hass( self.positions.primary, MAX_POSITION diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 0c09917d35b..4676a8d1505 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -11,8 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import HDEntity -from .model import PowerviewEntryData +from .model import PowerviewDeviceInfo, PowerviewEntryData async def async_setup_entry( @@ -22,7 +23,7 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - pvscenes = [] + pvscenes: list[PowerViewScene] = [] for raw_scene in pv_entry.scene_data.values(): scene = PvScene(raw_scene, pv_entry.api) room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") @@ -37,7 +38,13 @@ class PowerViewScene(HDEntity, Scene): _attr_icon = "mdi:blinds" - def __init__(self, coordinator, device_info, room_name, scene): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + scene: PvScene, + ) -> None: """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py new file mode 100644 index 00000000000..a5daf471a2d --- /dev/null +++ b/homeassistant/components/huum/__init__.py @@ -0,0 +1,46 @@ +"""The Huum integration.""" +from __future__ import annotations + +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Huum from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + huum = Huum(username, password, session=async_get_clientsession(hass)) + + try: + await huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise ConfigEntryNotReady( + "Could not log in to Huum with given credentials" + ) from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum + + 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/huum/climate.py b/homeassistant/components/huum/climate.py new file mode 100644 index 00000000000..2bc3c626deb --- /dev/null +++ b/homeassistant/components/huum/climate.py @@ -0,0 +1,133 @@ +"""Support for Huum wifi-enabled sauna.""" +from __future__ import annotations + +import logging +from typing import Any + +from huum.const import SaunaStatus +from huum.exceptions import SafetyException +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, 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 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Huum sauna with config flow.""" + huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] + + async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + + +class HuumDevice(ClimateEntity): + """Representation of a heater.""" + + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_max_temp = 110 + _attr_min_temp = 40 + _attr_has_entity_name = True + _attr_name = None + + _target_temperature: int | None = None + _status: HuumStatusResponse | None = None + _enable_turn_on_off_backwards_compatibility = False + + def __init__(self, huum_handler: Huum, unique_id: str) -> None: + """Initialize the heater.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name="Huum sauna", + manufacturer="Huum", + ) + + self._huum_handler = huum_handler + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + return HVACMode.HEAT + return HVACMode.OFF + + @property + def icon(self) -> str: + """Return nice icon for heater.""" + if self.hvac_mode == HVACMode.HEAT: + return "mdi:radiator" + return "mdi:radiator-off" + + @property + def current_temperature(self) -> int | None: + """Return the current temperature.""" + if (status := self._status) is not None: + return status.temperature + return None + + @property + def target_temperature(self) -> int: + """Return the temperature we try to reach.""" + return self._target_temperature or int(self.min_temp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + if hvac_mode == HVACMode.HEAT: + await self._turn_on(self.target_temperature) + elif hvac_mode == HVACMode.OFF: + await self._huum_handler.turn_off() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._target_temperature = temperature + + if self.hvac_mode == HVACMode.HEAT: + await self._turn_on(temperature) + + async def async_update(self) -> None: + """Get the latest status data. + + We get the latest status first from the status endpoints of the sauna. + If that data does not include the temperature, that means that the sauna + is off, we then call the off command which will in turn return the temperature. + This is a workaround for getting the temperature as the Huum API does not + return the target temperature of a sauna that is off, even if it can have + a target temperature at that time. + """ + self._status = await self._huum_handler.status_from_status_or_stop() + if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: + self._target_temperature = self._status.target_temperature + + async def _turn_on(self, temperature: int) -> None: + try: + await self._huum_handler.turn_on(temperature) + except (ValueError, SafetyException) as err: + _LOGGER.error(str(err)) + raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py new file mode 100644 index 00000000000..31f4c9a137c --- /dev/null +++ b/homeassistant/components/huum/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for huum integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +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 HuumConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for huum.""" + + VERSION = 1 + + 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: + try: + huum_handler = Huum( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + await huum_handler.status() + except (Forbidden, NotAuthenticated): + # Most likely Forbidden as that is what is returned from `.status()` with bad creds + _LOGGER.error("Could not log in to Huum with given credentials") + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + self._async_abort_entries_match( + {CONF_USERNAME: user_input[CONF_USERNAME]} + ) + 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/huum/const.py b/homeassistant/components/huum/const.py new file mode 100644 index 00000000000..69dea45b218 --- /dev/null +++ b/homeassistant/components/huum/const.py @@ -0,0 +1,7 @@ +"""Constants for the huum integration.""" + +from homeassistant.const import Platform + +DOMAIN = "huum" + +PLATFORMS = [Platform.CLIMATE] diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json new file mode 100644 index 00000000000..7629f529b91 --- /dev/null +++ b/homeassistant/components/huum/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "huum", + "name": "Huum", + "codeowners": ["@frwickst"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huum", + "iot_class": "cloud_polling", + "requirements": ["huum==0.7.10"] +} diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json new file mode 100644 index 00000000000..68ab1adde6f --- /dev/null +++ b/homeassistant/components/huum/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Huum", + "description": "Log in with the same username and password that is used in the Huum mobile app.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 054d084eb76..0bfe1dff001 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/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index ea038b3b408..42d9770656b 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -32,7 +32,7 @@ from .const import ( SIGNAL_INSTANCE_REMOVE, ) -PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.CAMERA] +PLATFORMS = [Platform.CAMERA, Platform.LIGHT, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index b2c1800914e..1b821025953 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -53,7 +53,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): +class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching iAlarm data.""" def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index b7dbe43fca9..5a81ad3d681 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -42,7 +42,12 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): """Representation of a thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, dev: AqualinkThermostat) -> None: """Initialize AquaLink thermostat.""" diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index f4d36c2e617..c7d6c358a29 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -2,12 +2,17 @@ from __future__ import annotations from typing import Any +from uuid import UUID + +import voluptuous as vol from homeassistant import config_entries from homeassistant.components import bluetooth +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import DOMAIN +from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -29,3 +34,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="iBeacon Tracker", data={}) return self.async_show_form(step_id="user") + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + 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 | None = None) -> FlowResult: + """Manage the options.""" + errors = {} + + current_uuids = self.config_entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + new_uuid = None + + if user_input is not None: + if new_uuid := user_input.get("new_uuid", "").lower(): + try: + # accept non-standard formats that can be fixed by UUID + new_uuid = str(UUID(new_uuid)) + except ValueError: + errors["new_uuid"] = "invalid_uuid_format" + + if not errors: + # don't modify current_uuids in memory, cause HA will think that the new + # data is equal to the old, and will refuse to write them to disk. + updated_uuids = user_input.get("allow_nameless_uuids", []) + if new_uuid and new_uuid not in updated_uuids: + updated_uuids.append(new_uuid) + + data = {CONF_ALLOW_NAMELESS_UUIDS: list(updated_uuids)} + return self.async_create_entry(title="", data=data) + + schema = { + vol.Optional( + "new_uuid", + description={"suggested_value": new_uuid}, + ): str, + } + if current_uuids: + schema |= { + vol.Optional( + "allow_nameless_uuids", + default=current_uuids, + ): cv.multi_select(sorted(current_uuids)) + } + return self.async_show_form( + step_id="init", errors=errors, data_schema=vol.Schema(schema) + ) diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 19b3a6f6599..041448101fa 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -46,3 +46,4 @@ MIN_SEEN_TRANSIENT_NEW = ( CONF_IGNORE_ADDRESSES = "ignore_addresses" CONF_IGNORE_UUIDS = "ignore_uuids" +CONF_ALLOW_NAMELESS_UUIDS = "allow_nameless_uuids" diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 537b4b8f860..b23ea77e013 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime +import logging import time from ibeacon_ble import ( @@ -21,6 +22,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from .const import ( + CONF_ALLOW_NAMELESS_UUIDS, CONF_IGNORE_ADDRESSES, CONF_IGNORE_UUIDS, DOMAIN, @@ -34,6 +36,8 @@ from .const import ( UPDATE_INTERVAL, ) +_LOGGER = logging.getLogger(__name__) + MONOTONIC_TIME = time.monotonic @@ -141,6 +145,16 @@ class IBeaconCoordinator: # iBeacons with random MAC addresses, fixed UUID, random major/minor self._major_minor_by_uuid: dict[str, set[tuple[int, int]]] = {} + # iBeacons from devices with no name + self._allow_nameless_uuids = set( + entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + ) + self._ignored_nameless_by_uuid: dict[str, set[str]] = {} + + self._entry.async_on_unload( + self._entry.add_update_listener(self.async_config_entry_updated) + ) + @callback def async_device_id_seen(self, device_id: str) -> bool: """Return True if the device_id has been seen since boot.""" @@ -248,6 +262,8 @@ class IBeaconCoordinator: if uuid_str in self._ignore_uuids: return + _LOGGER.debug("update beacon %s", uuid_str) + major = ibeacon_advertisement.major minor = ibeacon_advertisement.minor major_minor_by_uuid = self._major_minor_by_uuid.setdefault(uuid_str, set()) @@ -296,12 +312,24 @@ class IBeaconCoordinator: address = service_info.address unique_id = f"{group_id}_{address}" new = unique_id not in self._last_ibeacon_advertisement_by_unique_id - # Reject creating new trackers if the name is not set - if new and ( - service_info.device.name is None - or service_info.device.name.replace("-", ":") == service_info.device.address + uuid = str(ibeacon_advertisement.uuid) + + # Reject creating new trackers if the name is not set (unless the uuid is allowlisted). + if ( + new + and uuid not in self._allow_nameless_uuids + and ( + service_info.device.name is None + or service_info.device.name.replace("-", ":") + == service_info.device.address + ) ): + # Store the ignored addresses, cause the uuid might be allowlisted later + self._ignored_nameless_by_uuid.setdefault(uuid, set()).add(address) + + _LOGGER.debug("ignoring new beacon %s due to empty device name", unique_id) return + previously_tracked = address in self._unique_ids_by_address self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement self._async_track_ibeacon_with_unique_address(address, group_id, unique_id) @@ -428,6 +456,33 @@ class IBeaconCoordinator: ibeacon_advertisement, ) + async def async_config_entry_updated( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Restore ignored nameless beacons when the allowlist is updated.""" + + self._allow_nameless_uuids = set( + self._entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + ) + + for uuid in self._allow_nameless_uuids: + for address in self._ignored_nameless_by_uuid.pop(uuid, set()): + _LOGGER.debug( + "restoring nameless iBeacon %s from address %s", uuid, address + ) + + if not ( + service_info := bluetooth.async_last_service_info( + self.hass, address, connectable=False + ) + ): + continue # no longer available + + # the beacon was ignored, we need to re-process it from scratch + self._async_update_ibeacon( + service_info, bluetooth.BluetoothChange.ADVERTISEMENT + ) + @callback def _async_update(self, _now: datetime) -> None: """Update the Coordinator.""" diff --git a/homeassistant/components/ibeacon/strings.json b/homeassistant/components/ibeacon/strings.json index be3f7020cbe..440df8292a9 100644 --- a/homeassistant/components/ibeacon/strings.json +++ b/homeassistant/components/ibeacon/strings.json @@ -13,11 +13,15 @@ "options": { "step": { "init": { - "description": "iBeacons with an RSSI value lower than the Minimum RSSI will be ignored. If the integration is seeing neighboring iBeacons, increasing this value may help.", + "description": "iBeacons with an empty device name are ignored by default, unless their UUID is explicitly allowed in the list below.", "data": { - "min_rssi": "Minimum RSSI" + "new_uuid": "Enter a new allowed UUID", + "allow_nameless_uuids": "Currently allowed UUIDs. Uncheck to remove" } } + }, + "error": { + "invalid_uuid_format": "UUIDs should contain 32 hex characters grouped as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" } }, "entity": { diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 5e112aa39f7..c3e5f3de429 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): +class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage updates for the Idasen Desk.""" def __init__( diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 0a96a976bb3..80e07fe1065 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.4"] + "requirements": ["idasen-ha==2.5"] } diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 0f8b7a0fa74..736efcb03a7 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -1,8 +1,11 @@ """Support to trigger Maker IFTTT recipes.""" +from __future__ import annotations + from http import HTTPStatus import json import logging +from aiohttp import web import pyfttt import requests import voluptuous as vol @@ -91,7 +94,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def handle_webhook(hass, webhook_id, request): +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request +) -> None: """Handle webhook callback.""" body = await request.text() try: diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index a0b87bd4932..b568693303a 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -81,14 +81,14 @@ def setup_platform( if DATA_IFTTT_ALARM not in hass.data: hass.data[DATA_IFTTT_ALARM] = [] - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - code_arm_required = config.get(CONF_CODE_ARM_REQUIRED) - event_away = config.get(CONF_EVENT_AWAY) - event_home = config.get(CONF_EVENT_HOME) - event_night = config.get(CONF_EVENT_NIGHT) - event_disarm = config.get(CONF_EVENT_DISARM) - optimistic = config.get(CONF_OPTIMISTIC) + name: str = config[CONF_NAME] + code: str | None = config.get(CONF_CODE) + code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + event_away: str = config[CONF_EVENT_AWAY] + event_home: str = config[CONF_EVENT_HOME] + event_night: str = config[CONF_EVENT_NIGHT] + event_disarm: str = config[CONF_EVENT_DISARM] + optimistic: bool = config[CONF_OPTIMISTIC] alarmpanel = IFTTTAlarmPanel( name, @@ -135,15 +135,15 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): def __init__( self, - name, - code, - code_arm_required, - event_away, - event_home, - event_night, - event_disarm, - optimistic, - ): + name: str, + code: str | None, + code_arm_required: bool, + event_away: str, + event_home: str, + event_night: str, + event_disarm: str, + optimistic: bool, + ) -> None: """Initialize the alarm control panel.""" self._attr_name = name self._code = code @@ -187,7 +187,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): return self.set_alarm_state(self._event_night, STATE_ALARM_ARMED_NIGHT) - def set_alarm_state(self, event, state): + def set_alarm_state(self, event: str, state: str) -> None: """Call the IFTTT trigger service to change the alarm state.""" data = {ATTR_EVENT: event} @@ -196,7 +196,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): if self._optimistic: self._attr_state = state - def push_alarm_state(self, value): + def push_alarm_state(self, value: str) -> None: """Push the alarm state to the given value.""" if value in ALLOWED_STATES: _LOGGER.debug("Pushed the alarm state to %s", value) diff --git a/homeassistant/components/image/icons.json b/homeassistant/components/image/icons.json new file mode 100644 index 00000000000..cec9c99d765 --- /dev/null +++ b/homeassistant/components/image/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:image" + } + } +} diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 5ffeec1ca41..ba9140b4ed8 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 70594d5fd7c..dea7a0e2e71 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -169,11 +169,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} if not (errors := await validate_input(self.hass, user_input)): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data=user_input ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( description_placeholders={ diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 5591980b2f1..49938eaaa0a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -9,7 +9,7 @@ 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 +from typing import TYPE_CHECKING, Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException @@ -97,9 +97,8 @@ 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, charset: str = "utf-8") -> None: + def __init__(self, raw_message: bytes) -> None: """Initialize IMAP message.""" - self._charset = charset self.email_message = email.message_from_bytes(raw_message) @property @@ -153,7 +152,7 @@ class ImapMessage: def text(self) -> str: """Get the message text from the email. - Will look for text/plain or use text/html if not found. + Will look for text/plain or use/ text/html if not found. """ message_text: str | None = None message_html: str | None = None @@ -166,8 +165,13 @@ class ImapMessage: Falls back to the raw content part if decoding fails. """ try: - return str(part.get_payload(decode=True).decode(self._charset)) + decoded_payload: Any = part.get_payload(decode=True) + if TYPE_CHECKING: + assert isinstance(decoded_payload, bytes) + content_charset = part.get_content_charset() or "utf-8" + return decoded_payload.decode(content_charset) except ValueError: + # return undecoded payload return str(part.get_payload()) part: Message @@ -237,9 +241,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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], charset=self.config_entry.data[CONF_CHARSET] - ) + message = ImapMessage(response.lines[1]) # 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/imap/icons.json b/homeassistant/components/imap/icons.json new file mode 100644 index 00000000000..a4a79aef60e --- /dev/null +++ b/homeassistant/components/imap/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "imap_mail_count": { + "default": "mdi:email-alert-outline", + "state": { + "0": "mdi:email-check-outline" + } + } + } + } +} diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 92a66fabe49..07e77b31470 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -20,6 +20,8 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, + translation_key="imap_mail_count", + name=None, ) @@ -40,9 +42,7 @@ class ImapSensor( ): """Representation of an IMAP sensor.""" - _attr_icon = "mdi:email-outline" _attr_has_entity_name = True - _attr_name = None def __init__( self, diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 762f37ef5d4..6f940f91946 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any, TypeVar @@ -325,14 +325,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return if not self._provision_task: - self._provision_task = self.hass.async_create_task( - self._resume_flow_when_done(_do_provision()) - ) + self._provision_task = self.hass.async_create_task(_do_provision()) + + if not self._provision_task.done(): return self.async_show_progress( - step_id="do_provision", progress_action="provisioning" + step_id="do_provision", + progress_action="provisioning", + progress_task=self._provision_task, ) - await self._provision_task self._provision_task = None return self.async_show_progress_done(next_step_id="provision_done") @@ -347,14 +348,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._provision_result = None return result - async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: - try: - await awaitable - finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) - async def async_step_authorize( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -378,14 +371,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except AbortFlow as err: return self.async_abort(reason=err.reason) - self._authorize_task = self.hass.async_create_task( - self._resume_flow_when_done(authorized_event.wait()) - ) + self._authorize_task = self.hass.async_create_task(authorized_event.wait()) + + if not self._authorize_task.done(): return self.async_show_progress( - step_id="authorize", progress_action="authorize" + step_id="authorize", + progress_action="authorize", + progress_task=self._authorize_task, ) - await self._authorize_task self._authorize_task = None if self._unsub: self._unsub() diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index cae73495438..0dba00ff416 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -42,6 +42,7 @@ class InComfortClimate(IncomfortChild, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, client, heater, room) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/input_boolean/icons.json b/homeassistant/components/input_boolean/icons.json new file mode 100644 index 00000000000..dc595a60fba --- /dev/null +++ b/homeassistant/components/input_boolean/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:check-circle-outline", + "state": { + "off": "mdi:close-circle-outline" + } + } + }, + "services": { + "toggle": "mdi:toggle-switch", + "turn_off": "mdi:toggle-switch-off", + "turn_on": "mdi:toggle-switch", + "reload": "mdi:reload" + } +} diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 74fb11491c0..22bd776e1c8 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -87,10 +87,13 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = list(HVAC_MODES.values()) _attr_fan_modes = list(FAN_MODES.values()) _attr_min_humidity = 1 + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 1d4eee4a058..cf210963841 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.2", + "pyinsteon==1.5.3", "insteon-frontend-home-assistant==0.4.0" ], "usb": [ diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 0b1eda7201e..3a9e1d15ffe 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_METHOD, CONF_NAME, UnitOfTime @@ -58,7 +59,9 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]) + selector.EntitySelectorConfig( + domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] + ), ), vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.SelectSelector( selector.SelectSelectorConfig( diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json index 5c15b33a34a..9e5c597bd1a 100644 --- a/homeassistant/components/integration/manifest.json +++ b/homeassistant/components/integration/manifest.json @@ -1,6 +1,7 @@ { "domain": "integration", "name": "Integration - Riemann sum integral", + "after_dependencies": ["counter"], "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/integration", diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 5d305db8feb..9fed9c08bb6 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -49,10 +49,15 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = 0 _attr_max_temp = 37 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS last_temp = DEFAULT_THERMOSTAT_TEMP + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 5da3c3cdbf8..efcafd2acd8 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -69,7 +69,7 @@ class IntellifireFlameControlEntity(IntellifireEntity, NumberEntity): value_to_send: int = int(value) - 1 LOGGER.debug( "%s set flame height to %d with raw value %s", - self._attr_name, + self.name, value, value_to_send, ) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 306f169106b..5756b78b4de 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,6 +1,10 @@ """The Intent integration.""" -import logging +from __future__ import annotations +import logging +from typing import Any, Protocol + +from aiohttp import web import voluptuous as vol from homeassistant.components import http @@ -69,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class IntentPlatformProtocol(Protocol): + """Define the format that intent platforms can have.""" + + async def async_setup_intents(self, hass: HomeAssistant) -> None: + """Set up platform intents.""" + + class OnOffIntentHandler(intent.ServiceIntentHandler): """Intent handler for on/off that handles covers too.""" @@ -249,7 +260,9 @@ class NevermindIntentHandler(intent.IntentHandler): return intent_obj.create_response() -async def _async_process_intent(hass: HomeAssistant, domain: str, platform): +async def _async_process_intent( + hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol +) -> None: """Process the intents of an integration.""" await platform.async_setup_intents(hass) @@ -268,9 +281,9 @@ class IntentHandleView(http.HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle intent with name/data.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] language = hass.config.language try: @@ -286,7 +299,7 @@ class IntentHandleView(http.HomeAssistantView): intent_result.async_set_speech(str(err)) if intent_result is None: - intent_result = intent.IntentResponse(language=language) + intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable] intent_result.async_set_speech("Sorry, I couldn't handle that") return self.json(intent_result) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 16cf62627f1..64f52fae0a6 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -146,6 +146,7 @@ class IntesisAC(ClimateEntity): _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" @@ -204,6 +205,11 @@ class IntesisAC(ClimateEntity): self._attr_hvac_modes.extend(mode_list) self._attr_hvac_modes.append(HVACMode.OFF) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Subscribe to event updates.""" _LOGGER.debug("Added climate device with state: %s", repr(self._ih_device)) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index dd5ea743d57..291f08425fa 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,7 +1,9 @@ """Native Home Assistant iOS app component.""" import datetime from http import HTTPStatus +from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant import config_entries @@ -24,6 +26,8 @@ from .const import ( CONF_ACTION_LABEL_COLOR, CONF_ACTION_LABEL_TEXT, CONF_ACTION_NAME, + CONF_ACTION_SHOW_IN_CARPLAY, + CONF_ACTION_SHOW_IN_WATCH, CONF_ACTIONS, DOMAIN, ) @@ -145,6 +149,8 @@ ACTION_SCHEMA = vol.Schema( vol.Optional(CONF_ACTION_ICON_ICON): cv.string, vol.Optional(CONF_ACTION_ICON_COLOR): cv.string, }, + vol.Optional(CONF_ACTION_SHOW_IN_CARPLAY): cv.boolean, + vol.Optional(CONF_ACTION_SHOW_IN_WATCH): cv.boolean, }, ) @@ -218,7 +224,7 @@ CONFIGURATION_FILE = ".ios.conf" PLATFORMS = [Platform.SENSOR] -def devices_with_push(hass): +def devices_with_push(hass: HomeAssistant) -> dict[str, str]: """Return a dictionary of push enabled targets.""" return { device_name: device.get(ATTR_PUSH_ID) @@ -227,7 +233,7 @@ def devices_with_push(hass): } -def enabled_push_ids(hass): +def enabled_push_ids(hass: HomeAssistant) -> list[str]: """Return a list of push enabled target push IDs.""" return [ device.get(ATTR_PUSH_ID) @@ -236,16 +242,16 @@ def enabled_push_ids(hass): ] -def devices(hass): +def devices(hass: HomeAssistant) -> dict[str, dict[str, Any]]: """Return a dictionary of all identified devices.""" - return hass.data[DOMAIN][ATTR_DEVICES] + return hass.data[DOMAIN][ATTR_DEVICES] # type: ignore[no-any-return] -def device_name_for_push_id(hass, push_id): +def device_name_for_push_id(hass: HomeAssistant, push_id: str) -> str | None: """Return the device name for the push ID.""" for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items(): if device.get(ATTR_PUSH_ID) is push_id: - return device_name + return device_name # type: ignore[no-any-return] return None @@ -299,12 +305,12 @@ class iOSPushConfigView(HomeAssistantView): url = "/api/ios/push" name = "api:ios:push" - def __init__(self, push_config): + def __init__(self, push_config: dict[str, Any]) -> None: """Init the view.""" self.push_config = push_config @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle the GET request for the push configuration.""" return self.json(self.push_config) @@ -315,12 +321,12 @@ class iOSConfigView(HomeAssistantView): url = "/api/ios/config" name = "api:ios:config" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Init the view.""" self.config = config @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle the GET request for the user-defined configuration.""" return self.json(self.config) @@ -331,18 +337,18 @@ class iOSIdentifyDeviceView(HomeAssistantView): url = "/api/ios/identify" name = "api:ios:identify" - def __init__(self, config_path): + def __init__(self, config_path: str) -> None: """Initialize the view.""" self._config_path = config_path - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle the POST request for device identification.""" try: data = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py index 3e6b2155add..41da1954b44 100644 --- a/homeassistant/components/ios/const.py +++ b/homeassistant/components/ios/const.py @@ -11,3 +11,5 @@ CONF_ACTION_ICON = "icon" CONF_ACTION_ICON_COLOR = "color" CONF_ACTION_ICON_ICON = "icon" CONF_ACTIONS = "actions" +CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay" +CONF_ACTION_SHOW_IN_WATCH = "show_in_watch" diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index de6091e3638..a8d1b2514cd 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus import logging +from typing import Any import requests @@ -25,11 +26,13 @@ _LOGGER = logging.getLogger(__name__) PUSH_URL = "https://ios-push.home-assistant.io/push" -def log_rate_limits(hass, target, resp, level=20): +def log_rate_limits( + hass: HomeAssistant, target: str, resp: dict[str, Any], level: int = 20 +) -> None: """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) - resetsAtTime = resetsAt - dt_util.utcnow() + resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---" rate_limit_msg = ( "iOS push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " @@ -69,13 +72,13 @@ class iOSNotificationService(BaseNotificationService): """Initialize the service.""" @property - def targets(self): + def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return ios.devices_with_push(self.hass) - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the Lambda APNS gateway.""" - data = {ATTR_MESSAGE: message} + data: dict[str, Any] = {ATTR_MESSAGE: message} # Remove default title from notifications. if ( diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 610cea8c814..6c6642a0226 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,6 +1,8 @@ """Support for Home Assistant iOS app sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -66,7 +68,10 @@ class IOSSensor(SensorEntity): _attr_has_entity_name = True def __init__( - self, device_name, device, description: SensorEntityDescription + self, + device_name: str, + device: dict[str, Any], + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -92,7 +97,7 @@ class IOSSensor(SensorEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" device = self._device[ios.ATTR_DEVICE] device_battery = self._device[ios.ATTR_BATTERY] @@ -105,7 +110,7 @@ class IOSSensor(SensorEntity): } @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" device_battery = self._device[ios.ATTR_BATTERY] battery_state = device_battery[ios.ATTR_BATTERY_STATE] @@ -128,7 +133,7 @@ class IOSSensor(SensorEntity): return icon_for_battery_level(battery_level=battery_level, charging=charging) @callback - def _update(self, device): + def _update(self, device: dict[str, Any]) -> None: """Get the latest state of the sensor.""" self._device = device self._attr_native_value = self._device[ios.ATTR_BATTERY][ diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 5ff89fa8ed5..4cb8f921ba4 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -17,7 +17,7 @@ from .const import DATA_API, DATA_LOCATION, DOMAIN DEFAULT_NAME = "ipma" -PLATFORMS = [Platform.WEATHER, Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 3ac2fd18473..06b73978456 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -82,9 +82,12 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1.0 _attr_fan_modes = [FAN_AUTO, FAN_ON] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 9f16b4a0d0c..2cdfd1df16d 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -276,10 +276,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" else: - cfg_entries = self.hass.config_entries - cfg_entries.async_update_entry(existing_entry, data=new_data) - await cfg_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort( + self._existing_entry, data=new_data + ) self.context["title_placeholders"] = { CONF_NAME: existing_entry.title, diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 7d7696755cf..a6adfcfb917 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ) 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,10 +121,18 @@ SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( ) +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.""" - existing_services = hass.services.async_services().get(DOMAIN) + existing_services = hass.services.async_services_for_domain(DOMAIN) if existing_services and SERVICE_SEND_PROGRAM_COMMAND in existing_services: # Integration-level services have already been added. Return. return @@ -159,7 +168,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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( @@ -225,7 +234,7 @@ def async_unload_services(hass: HomeAssistant) -> None: # There is still another config entry for this domain, don't remove services. return - existing_services = hass.services.async_services().get(DOMAIN) + existing_services = hass.services.async_services_for_domain(DOMAIN) if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 1ff016c3177..e85b7ef4d56 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,9 +1,9 @@ """Support for the iZone HVAC.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar from pizone import Controller, Zone import voluptuous as vol @@ -47,6 +47,12 @@ from .const import ( IZONE, ) +_DeviceT = TypeVar("_DeviceT", bound="ControllerDevice | ZoneDevice") +_T = TypeVar("_T") +_R = TypeVar("_R") +_P = ParamSpec("_P") +_FuncType = Callable[Concatenate[_T, _P], _R] + _LOGGER = logging.getLogger(__name__) _IZONE_FAN_TO_HA = { @@ -112,13 +118,15 @@ async def async_setup_entry( ) -def _return_on_connection_error(ret=None): - def wrap(func): - def wrapped_f(*args, **kwargs): - if not args[0].available: +def _return_on_connection_error( + ret: _T = None, # type: ignore[assignment] +) -> Callable[[_FuncType[_DeviceT, _P, _R]], _FuncType[_DeviceT, _P, _R | _T]]: + def wrap(func: _FuncType[_DeviceT, _P, _R]) -> _FuncType[_DeviceT, _P, _R | _T]: + def wrapped_f(self: _DeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R | _T: + if not self.available: return ret try: - return func(*args, **kwargs) + return func(self, *args, **kwargs) except ConnectionError: return ret @@ -136,12 +144,17 @@ class ControllerDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" self._controller = controller - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, # otherwise the unit determines which zone to use as target. See interface manual p. 8 @@ -498,7 +511,7 @@ class ZoneDevice(ClimateEntity): return self._controller.available @property - @_return_on_connection_error(0) + @_return_on_connection_error(ClimateEntityFeature(0)) def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" if self._zone.mode == Zone.Mode.AUTO: diff --git a/homeassistant/components/jellyfin/icons.json b/homeassistant/components/jellyfin/icons.json new file mode 100644 index 00000000000..6dcfa4b2706 --- /dev/null +++ b/homeassistant/components/jellyfin/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "watching": { + "default": "mdi:television-play" + } + } + } +} diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 0f1afd30e9b..df503d14378 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -42,8 +42,8 @@ def _count_now_playing(data: JellyfinDataT) -> int: SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = { "sessions": JellyfinSensorEntityDescription( key="watching", + translation_key="watching", name=None, - icon="mdi:television-play", native_unit_of_measurement="Watching", value_fn=_count_now_playing, ) diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index c1744b30b1a..bcefe763e15 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -20,7 +20,7 @@ from .device import JuiceNetApi _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] CONFIG_SCHEMA = vol.Schema( vol.All( diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 695faa4f529..c30e213814e 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN, PLATFORMS from .coordinator import JustNimbusCoordinator @@ -10,7 +11,10 @@ from .coordinator import JustNimbusCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JustNimbus from a config entry.""" - coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + if "zip_code" in entry.data: + coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + else: + raise ConfigEntryAuthFailed() await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/justnimbus/config_flow.py b/homeassistant/components/justnimbus/config_flow.py index bb55b1852b8..536943ef607 100644 --- a/homeassistant/components/justnimbus/config_flow.py +++ b/homeassistant/components/justnimbus/config_flow.py @@ -1,6 +1,7 @@ """Config flow for JustNimbus integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,13 +13,14 @@ from homeassistant.const import CONF_CLIENT_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from .const import DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ZIP_CODE): cv.string, }, ) @@ -27,6 +29,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for JustNimbus.""" VERSION = 1 + reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -39,10 +42,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} - await self.async_set_unique_id(user_input[CONF_CLIENT_ID]) - self._abort_if_unique_id_configured() + unique_id = f"{user_input[CONF_CLIENT_ID]}{user_input[CONF_ZIP_CODE]}" + await self.async_set_unique_id(unique_id=unique_id) + if not self.reauth_entry: + self._abort_if_unique_id_configured() - client = justnimbus.JustNimbusClient(client_id=user_input[CONF_CLIENT_ID]) + client = justnimbus.JustNimbusClient( + client_id=user_input[CONF_CLIENT_ID], zip_code=user_input[CONF_ZIP_CODE] + ) try: await self.hass.async_add_executor_job(client.get_data) except justnimbus.InvalidClientID: @@ -53,8 +60,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title="JustNimbus", data=user_input) + if not self.reauth_entry: + return self.async_create_entry(title="JustNimbus", data=user_input) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=user_input, unique_id=unique_id + ) + + # Reload the config entry otherwise devices will remain unavailable + 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="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() diff --git a/homeassistant/components/justnimbus/const.py b/homeassistant/components/justnimbus/const.py index cf3d4ef825f..11a4ae487c4 100644 --- a/homeassistant/components/justnimbus/const.py +++ b/homeassistant/components/justnimbus/const.py @@ -1,13 +1,14 @@ """Constants for the JustNimbus integration.""" + from typing import Final from homeassistant.const import Platform DOMAIN = "justnimbus" -VOLUME_FLOW_RATE_LITERS_PER_MINUTE: Final = "L/min" - PLATFORMS = [ Platform.SENSOR, ] + +CONF_ZIP_CODE: Final = "zip_code" diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index 606cea0e922..9dc7dcbc743 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_CLIENT_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,9 @@ class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): name=DOMAIN, update_interval=timedelta(minutes=1), ) - self._client = justnimbus.JustNimbusClient(client_id=entry.data[CONF_CLIENT_ID]) + self._client = justnimbus.JustNimbusClient( + client_id=entry.data[CONF_CLIENT_ID], zip_code=entry.data[CONF_ZIP_CODE] + ) async def _async_update_data(self) -> justnimbus.JustNimbusModel: """Fetch the latest data from the source.""" diff --git a/homeassistant/components/justnimbus/manifest.json b/homeassistant/components/justnimbus/manifest.json index 76c5060376b..26cbc80e166 100644 --- a/homeassistant/components/justnimbus/manifest.json +++ b/homeassistant/components/justnimbus/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/justnimbus", "iot_class": "cloud_polling", - "requirements": ["justnimbus==0.6.0"] + "requirements": ["justnimbus==0.7.3"] } diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index cb428fa5eea..14b89b6c2c1 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( EntityCategory, UnitOfPressure, UnitOfTemperature, - UnitOfTime, UnitOfVolume, ) from homeassistant.core import HomeAssistant @@ -25,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import JustNimbusCoordinator -from .const import DOMAIN, VOLUME_FLOW_RATE_LITERS_PER_MINUTE +from .const import DOMAIN from .entity import JustNimbusEntity @@ -44,54 +43,20 @@ class JustNimbusEntityDescription( SENSOR_TYPES = ( - JustNimbusEntityDescription( - key="pump_flow", - translation_key="pump_flow", - icon="mdi:pump", - native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_flow, - ), - JustNimbusEntityDescription( - key="drink_flow", - translation_key="drink_flow", - icon="mdi:water-pump", - native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.drink_flow, - ), JustNimbusEntityDescription( key="pump_pressure", translation_key="pump_pressure", + icon="mdi:water-pump", native_unit_of_measurement=UnitOfPressure.BAR, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: coordinator.data.pump_pressure, ), - JustNimbusEntityDescription( - key="pump_starts", - translation_key="pump_starts", - icon="mdi:restart", - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_starts, - ), - JustNimbusEntityDescription( - key="pump_hours", - translation_key="pump_hours", - icon="mdi:clock", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_hours, - ), JustNimbusEntityDescription( key="reservoir_temp", translation_key="reservoir_temperature", + icon="mdi:coolant-temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -104,57 +69,46 @@ SENSOR_TYPES = ( icon="mdi:car-coolant-level", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: coordinator.data.reservoir_content, ), JustNimbusEntityDescription( - key="total_saved", - translation_key="total_saved", + key="water_saved", + translation_key="water_saved", icon="mdi:water-opacity", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.total_saved, + value_fn=lambda coordinator: coordinator.data.water_saved, ), JustNimbusEntityDescription( - key="total_replenished", - translation_key="total_replenished", - icon="mdi:water", - native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.total_replenished, - ), - JustNimbusEntityDescription( - key="error_code", - translation_key="error_code", - icon="mdi:bug", - entity_registry_enabled_default=False, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.error_code, - ), - JustNimbusEntityDescription( - key="totver", - translation_key="total_use", + key="water_used", + translation_key="water_used", icon="mdi:chart-donut", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.totver, + value_fn=lambda coordinator: coordinator.data.water_used, ), JustNimbusEntityDescription( - key="reservoir_content_max", - translation_key="reservoir_content_max", + key="reservoir_capacity", + translation_key="reservoir_capacity", icon="mdi:waves", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.reservoir_content_max, + value_fn=lambda coordinator: coordinator.data.reservoir_capacity, + ), + JustNimbusEntityDescription( + key="pump_type", + translation_key="pump_type", + icon="mdi:pump", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_type, ), ) diff --git a/homeassistant/components/justnimbus/strings.json b/homeassistant/components/justnimbus/strings.json index 92ebf19714a..bb9d0a44ebe 100644 --- a/homeassistant/components/justnimbus/strings.json +++ b/homeassistant/components/justnimbus/strings.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "client_id": "Client ID" + "client_id": "Client ID", + "zip_code": "ZIP code" } } }, @@ -13,46 +14,32 @@ "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": { "sensor": { - "pump_flow": { - "name": "Pump flow" - }, - "drink_flow": { - "name": "Drink flow" - }, "pump_pressure": { "name": "Pump pressure" }, - "pump_starts": { - "name": "Pump starts" + "pump_type": { + "name": "Pump type" }, - "pump_hours": { - "name": "Pump hours" - }, - "reservoir_temperature": { - "name": "Reservoir temperature" + "reservoir_capacity": { + "name": "Reservoir capacity" }, "reservoir_content": { "name": "Reservoir content" }, - "total_saved": { - "name": "Total saved" + "reservoir_temperature": { + "name": "Reservoir temperature" }, - "total_replenished": { - "name": "Total replenished" - }, - "error_code": { - "name": "Error code" - }, - "total_use": { + "water_used": { "name": "Total use" }, - "reservoir_content_max": { - "name": "Maximum reservoir content" + "water_saved": { + "name": "Total saved" } } } diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py index 996d745a1d5..33af1d315f7 100644 --- a/homeassistant/components/jvc_projector/__init__.py +++ b/homeassistant/components/jvc_projector/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .coordinator import JvcProjectorDataUpdateCoordinator -PLATFORMS = [Platform.REMOTE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py new file mode 100644 index 00000000000..7e8788aa0a6 --- /dev/null +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -0,0 +1,44 @@ +"""Binary Sensor platform for JVC Projector integration.""" + +from __future__ import annotations + +from jvcprojector import const + +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 . import JvcProjectorDataUpdateCoordinator +from .const import DOMAIN +from .entity import JvcProjectorEntity + +ON_STATUS = (const.ON, const.WARMING) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator: JvcProjectorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([JvcBinarySensor(coordinator)]) + + +class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity): + """The entity class for JVC Projector Binary Sensor.""" + + _attr_translation_key = "jvc_power" + + def __init__( + self, + coordinator: JvcProjectorDataUpdateCoordinator, + ) -> None: + """Initialize the JVC Projector sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.device.mac}_power" + + @property + def is_on(self) -> bool: + """Return true if the JVC is on.""" + return self.coordinator.data["power"] in ON_STATUS diff --git a/homeassistant/components/jvc_projector/icons.json b/homeassistant/components/jvc_projector/icons.json new file mode 100644 index 00000000000..94e2ec41cf6 --- /dev/null +++ b/homeassistant/components/jvc_projector/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "jvc_power": { + "default": "mdi:projector-off", + "state": { + "on": "mdi:projector" + } + } + } + } +} diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index bc01da5d89a..de7e77197f2 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -1,11 +1,11 @@ { "domain": "jvc_projector", "name": "JVC Projector", - "codeowners": ["@SteveEasley"], + "codeowners": ["@SteveEasley", "@msavazzi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jvc_projector", "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.0.6"] + "requirements": ["pyjvcprojector==1.0.9"] } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index 45f797a5aaa..dcc9e5cff51 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -36,6 +36,18 @@ COMMANDS = { "lens_control": const.REMOTE_LENS_CONTROL, "setting_memory": const.REMOTE_SETTING_MEMORY, "gamma_settings": const.REMOTE_GAMMA_SETTINGS, + "hdmi_1": const.REMOTE_HDMI_1, + "hdmi_2": const.REMOTE_HDMI_2, + "mode_1": const.REMOTE_MODE_1, + "mode_2": const.REMOTE_MODE_2, + "mode_3": const.REMOTE_MODE_3, + "lens_ap": const.REMOTE_LENS_AP, + "gamma": const.REMOTE_GAMMA, + "color_temp": const.REMOTE_COLOR_TEMP, + "natural": const.REMOTE_NATURAL, + "cinema": const.REMOTE_CINEMA, + "anamo": const.REMOTE_ANAMO, + "3d_format": const.REMOTE_3D_FORMAT, } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 6fdc5b4d12f..06efdc8f9aa 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -31,5 +31,12 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "entity": { + "binary_sensor": { + "jvc_power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + } + } } } diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py index c77b3c34564..a73a698f80a 100644 --- a/homeassistant/components/kaiterra/const.py +++ b/homeassistant/components/kaiterra/const.py @@ -72,4 +72,4 @@ DEFAULT_AQI_STANDARD = "us" DEFAULT_PREFERRED_UNIT: list[str] = [] DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -PLATFORMS = [Platform.SENSOR, Platform.AIR_QUALITY] +PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR] diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index e8176b152a6..5665dc27d17 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -138,6 +138,8 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): await self._client.connect(init=True) return self.async_show_form(step_id="link") + if not await self._client.is_connected(): + await self._client.connect(init=False) if not await self._client.is_connected(): errors["base"] = "linking" else: diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 760cc67cbd5..ee07881a01e 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,7 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", + "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.9"] + "requirements": ["PyMicroBot==0.0.10"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 5c8088823b2..8369892be85 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -61,6 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if "recorder" in hass.config.components: await _insert_statistics(hass) + # Start a reauth flow + config_entry.async_start_reauth(hass) + return True diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index ded2b84e31c..54104784c50 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -20,3 +20,13 @@ class KitchenSinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Kitchen Sink", data=import_info) + + async def async_step_reauth(self, data): + """Reauth step.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Reauth confirm step.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index ce907a3368d..dca42ce8361 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -1,4 +1,11 @@ { + "config": { + "step": { + "reauth_confirm": { + "description": "Press SUBMIT to reauthenticate" + } + } + }, "issues": { "bad_psu": { "title": "The power supply is not stable", diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 6cecea12f22..f3c1f75d818 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -29,5 +29,12 @@ } } } + }, + "entity": { + "switch": { + "relay": { + "name": "Relay {relay_id}" + } + } } } diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index cd1b181803f..144c05e927e 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -32,6 +32,9 @@ async def async_setup_entry( class KMtronicSwitch(CoordinatorEntity, SwitchEntity): """KMtronic Switch Entity.""" + _attr_translation_key = "relay" + _attr_has_entity_name = True + def __init__(self, hub, coordinator, relay, reverse, config_entry_id): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) @@ -46,7 +49,7 @@ class KMtronicSwitch(CoordinatorEntity, SwitchEntity): configuration_url=hub.host, ) - self._attr_name = f"Relay{relay.id}" + self._attr_translation_placeholders = {"relay_id": relay.id} self._attr_unique_id = f"{config_entry_id}_relay{relay.id}" @property diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 3444e9b002a..c6869f34eeb 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -5,23 +5,17 @@ import asyncio import contextlib import logging from pathlib import Path -from typing import Final import voluptuous as vol from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.telegram_queue import TelegramQueue -from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException from xknx.io import ConnectionConfig, ConnectionType, SecureConfig from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import ( - DeviceGroupAddress, - GroupAddress, - InternalGroupAddress, - parse_device_group_address, -) -from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,15 +24,13 @@ from homeassistant.const import ( CONF_PORT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, - SERVICE_RELOAD, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config -from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -95,24 +87,14 @@ from .schema import ( TextSchema, TimeSchema, WeatherSchema, - ga_validator, - sensor_type_validator, ) +from .services import register_knx_services from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel _LOGGER = logging.getLogger(__name__) -SERVICE_KNX_SEND: Final = "send" -SERVICE_KNX_ATTR_PAYLOAD: Final = "payload" -SERVICE_KNX_ATTR_TYPE: Final = "type" -SERVICE_KNX_ATTR_RESPONSE: Final = "response" -SERVICE_KNX_ATTR_REMOVE: Final = "remove" -SERVICE_KNX_EVENT_REGISTER: Final = "event_register" -SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" -SERVICE_KNX_READ: Final = "read" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -158,69 +140,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_KNX_SEND_SCHEMA = vol.Any( - vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, - vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator, - vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, - } - ), - vol.Schema( - # without type given payload is treated as raw bytes - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( - cv.positive_int, [cv.positive_int] - ), - vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, - } - ), -) - -SERVICE_KNX_READ_SCHEMA = vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ) - } -) - -SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Optional(CONF_TYPE): sensor_type_validator, - vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, - } -) - -SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( - ExposeSchema.EXPOSE_SENSOR_SCHEMA.extend( - { - vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, - } - ), - vol.Schema( - # for removing only `address` is required - { - vol.Required(KNX_ADDRESS): ga_validator, - vol.Required(SERVICE_KNX_ATTR_REMOVE): vol.All(cv.boolean, True), - }, - extra=vol.ALLOW_EXTRA, - ), -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start the KNX integration.""" @@ -235,6 +154,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + register_knx_services(hass) + return True @@ -287,43 +208,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.services.async_register( - DOMAIN, - SERVICE_KNX_SEND, - knx_module.service_send_to_knx_bus, - schema=SERVICE_KNX_SEND_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_KNX_READ, - knx_module.service_read_to_knx_bus, - schema=SERVICE_KNX_READ_SCHEMA, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_KNX_EVENT_REGISTER, - knx_module.service_event_register_modify, - schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_KNX_EXPOSURE_REGISTER, - knx_module.service_exposure_register_modify, - schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, - ) - - async def _reload_integration(call: ServiceCall) -> None: - """Reload the integration.""" - await hass.config_entries.async_reload(entry.entry_id) - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) - - async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_integration) - await register_panel(hass) return True @@ -419,10 +303,8 @@ class KNXModule: ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self._knx_event_callback: TelegramQueue.Callback = ( - self.register_event_callback() - ) + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) @@ -555,7 +437,7 @@ class KNXModule: ): data = telegram.payload.value.value if transcoder := ( - self._group_address_transcoder.get(telegram.destination_address) + self.group_address_transcoder.get(telegram.destination_address) or next( ( _transcoder @@ -612,111 +494,3 @@ class KNXModule: group_addresses=[], match_for_outgoing=True, ) - - async def service_event_register_modify(self, call: ServiceCall) -> None: - """Service for adding or removing a GroupAddress to the knx_event filter.""" - attr_address = call.data[KNX_ADDRESS] - group_addresses = list(map(parse_device_group_address, attr_address)) - - if call.data.get(SERVICE_KNX_ATTR_REMOVE): - for group_address in group_addresses: - try: - self._knx_event_callback.group_addresses.remove(group_address) - except ValueError: - _LOGGER.warning( - "Service event_register could not remove event for '%s'", - str(group_address), - ) - if group_address in self._group_address_transcoder: - del self._group_address_transcoder[group_address] - return - - if (dpt := call.data.get(CONF_TYPE)) and ( - transcoder := DPTBase.parse_transcoder(dpt) - ): - self._group_address_transcoder.update( - { - _address: transcoder # type: ignore[type-abstract] - for _address in group_addresses - } - ) - for group_address in group_addresses: - if group_address in self._knx_event_callback.group_addresses: - continue - self._knx_event_callback.group_addresses.append(group_address) - _LOGGER.debug( - "Service event_register registered event for '%s'", - str(group_address), - ) - - async def service_exposure_register_modify(self, call: ServiceCall) -> None: - """Service for adding or removing an exposure to KNX bus.""" - group_address = call.data[KNX_ADDRESS] - - if call.data.get(SERVICE_KNX_ATTR_REMOVE): - try: - removed_exposure = self.service_exposures.pop(group_address) - except KeyError as err: - raise HomeAssistantError( - f"Could not find exposure for '{group_address}' to remove." - ) from err - - removed_exposure.shutdown() - return - - if group_address in self.service_exposures: - replaced_exposure = self.service_exposures.pop(group_address) - _LOGGER.warning( - ( - "Service exposure_register replacing already registered exposure" - " for '%s' - %s" - ), - group_address, - replaced_exposure.device.name, - ) - replaced_exposure.shutdown() - exposure = create_knx_exposure(self.hass, self.xknx, call.data) - self.service_exposures[group_address] = exposure - _LOGGER.debug( - "Service exposure_register registered exposure for '%s' - %s", - group_address, - exposure.device.name, - ) - - async def service_send_to_knx_bus(self, call: ServiceCall) -> None: - """Service for sending an arbitrary KNX message to the KNX bus.""" - attr_address = call.data[KNX_ADDRESS] - attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] - attr_type = call.data.get(SERVICE_KNX_ATTR_TYPE) - attr_response = call.data[SERVICE_KNX_ATTR_RESPONSE] - - payload: DPTBinary | DPTArray - if attr_type is not None: - transcoder = DPTBase.parse_transcoder(attr_type) - if transcoder is None: - raise ValueError(f"Invalid type for knx.send service: {attr_type}") - payload = transcoder.to_knx(attr_payload) - elif isinstance(attr_payload, int): - payload = DPTBinary(attr_payload) - else: - payload = DPTArray(attr_payload) - - for address in attr_address: - telegram = Telegram( - destination_address=parse_device_group_address(address), - payload=GroupValueResponse(payload) - if attr_response - else GroupValueWrite(payload), - source_address=self.xknx.current_address, - ) - await self.xknx.telegrams.put(telegram) - - async def service_read_to_knx_bus(self, call: ServiceCall) -> None: - """Service for sending a GroupValueRead telegram to the KNX bus.""" - for address in call.data[KNX_ADDRESS]: - telegram = Telegram( - destination_address=parse_device_group_address(address), - payload=GroupValueRead(), - source_address=self.xknx.current_address, - ) - await self.xknx.telegrams.put(telegram) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 72039e1300f..1038cdde80f 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -134,12 +134,17 @@ class KNXClimate(KnxEntity, ClimateEntity): _device: XknxClimate _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of a KNX climate device.""" super().__init__(_create_climate(xknx, config)) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + if self._device.supports_on_off: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF if self.preset_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_target_temperature_step = self._device.temperature_step diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index aa48bcdf557..8cb1986c540 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -88,6 +88,15 @@ SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict" AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] MessageCallbackType = Callable[[Telegram], None] +SERVICE_KNX_SEND: Final = "send" +SERVICE_KNX_ATTR_PAYLOAD: Final = "payload" +SERVICE_KNX_ATTR_TYPE: Final = "type" +SERVICE_KNX_ATTR_RESPONSE: Final = "response" +SERVICE_KNX_ATTR_REMOVE: Final = "remove" +SERVICE_KNX_EVENT_REGISTER: Final = "event_register" +SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" +SERVICE_KNX_READ: Final = "read" + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a233ca38705..397af9ac181 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.11.2", - "xknxproject==3.4.0", - "knx-frontend==2023.6.23.191712" + "xknx==2.12.0", + "xknxproject==3.5.0", + "knx-frontend==2024.1.20.105944" ] } diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py new file mode 100644 index 00000000000..99c44a5eee6 --- /dev/null +++ b/homeassistant/components/knx/services.py @@ -0,0 +1,284 @@ +"""KNX integration services.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import TYPE_CHECKING + +import voluptuous as vol +from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.telegram import Telegram +from xknx.telegram.address import parse_device_group_address +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite + +from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_register_admin_service + +from .const import ( + DOMAIN, + KNX_ADDRESS, + SERVICE_KNX_ATTR_PAYLOAD, + SERVICE_KNX_ATTR_REMOVE, + SERVICE_KNX_ATTR_RESPONSE, + SERVICE_KNX_ATTR_TYPE, + SERVICE_KNX_EVENT_REGISTER, + SERVICE_KNX_EXPOSURE_REGISTER, + SERVICE_KNX_READ, + SERVICE_KNX_SEND, +) +from .expose import create_knx_exposure +from .schema import ExposeSchema, ga_validator, sensor_type_validator + +if TYPE_CHECKING: + from . import KNXModule + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_knx_services(hass: HomeAssistant) -> None: + """Register KNX integration services.""" + hass.services.async_register( + DOMAIN, + SERVICE_KNX_SEND, + partial(service_send_to_knx_bus, hass), + schema=SERVICE_KNX_SEND_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_KNX_READ, + partial(service_read_to_knx_bus, hass), + schema=SERVICE_KNX_READ_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_KNX_EVENT_REGISTER, + partial(service_event_register_modify, hass), + schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_KNX_EXPOSURE_REGISTER, + partial(service_exposure_register_modify, hass), + schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + partial(service_reload_integration, hass), + ) + + +@callback +def get_knx_module(hass: HomeAssistant) -> KNXModule: + """Return KNXModule instance.""" + try: + return hass.data[DOMAIN] # type: ignore[no-any-return] + except KeyError as err: + raise HomeAssistantError("KNX entry not loaded") from err + + +SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Optional(CONF_TYPE): sensor_type_validator, + vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, + } +) + + +async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for adding or removing a GroupAddress to the knx_event filter.""" + knx_module = get_knx_module(hass) + + attr_address = call.data[KNX_ADDRESS] + group_addresses = list(map(parse_device_group_address, attr_address)) + + if call.data.get(SERVICE_KNX_ATTR_REMOVE): + for group_address in group_addresses: + try: + knx_module.knx_event_callback.group_addresses.remove(group_address) + except ValueError: + _LOGGER.warning( + "Service event_register could not remove event for '%s'", + str(group_address), + ) + if group_address in knx_module.group_address_transcoder: + del knx_module.group_address_transcoder[group_address] + return + + if (dpt := call.data.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + knx_module.group_address_transcoder.update( + { + _address: transcoder # type: ignore[type-abstract] + for _address in group_addresses + } + ) + for group_address in group_addresses: + if group_address in knx_module.knx_event_callback.group_addresses: + continue + knx_module.knx_event_callback.group_addresses.append(group_address) + _LOGGER.debug( + "Service event_register registered event for '%s'", + str(group_address), + ) + + +SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( + ExposeSchema.EXPOSE_SENSOR_SCHEMA.extend( + { + vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, + } + ), + vol.Schema( + # for removing only `address` is required + { + vol.Required(KNX_ADDRESS): ga_validator, + vol.Required(SERVICE_KNX_ATTR_REMOVE): vol.All(cv.boolean, True), + }, + extra=vol.ALLOW_EXTRA, + ), +) + + +async def service_exposure_register_modify( + hass: HomeAssistant, call: ServiceCall +) -> None: + """Service for adding or removing an exposure to KNX bus.""" + knx_module = get_knx_module(hass) + + group_address = call.data[KNX_ADDRESS] + + if call.data.get(SERVICE_KNX_ATTR_REMOVE): + try: + removed_exposure = knx_module.service_exposures.pop(group_address) + except KeyError as err: + raise ServiceValidationError( + f"Could not find exposure for '{group_address}' to remove." + ) from err + + removed_exposure.shutdown() + return + + if group_address in knx_module.service_exposures: + replaced_exposure = knx_module.service_exposures.pop(group_address) + _LOGGER.warning( + ( + "Service exposure_register replacing already registered exposure" + " for '%s' - %s" + ), + group_address, + replaced_exposure.device.name, + ) + replaced_exposure.shutdown() + exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data) + knx_module.service_exposures[group_address] = exposure + _LOGGER.debug( + "Service exposure_register registered exposure for '%s' - %s", + group_address, + exposure.device.name, + ) + + +SERVICE_KNX_SEND_SCHEMA = vol.Any( + vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, + vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator, + vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, + } + ), + vol.Schema( + # without type given payload is treated as raw bytes + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, + } + ), +) + + +async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for sending an arbitrary KNX message to the KNX bus.""" + knx_module = get_knx_module(hass) + + attr_address = call.data[KNX_ADDRESS] + attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] + attr_type = call.data.get(SERVICE_KNX_ATTR_TYPE) + attr_response = call.data[SERVICE_KNX_ATTR_RESPONSE] + + payload: DPTBinary | DPTArray + if attr_type is not None: + transcoder = DPTBase.parse_transcoder(attr_type) + if transcoder is None: + raise ValueError(f"Invalid type for knx.send service: {attr_type}") + payload = transcoder.to_knx(attr_payload) + elif isinstance(attr_payload, int): + payload = DPTBinary(attr_payload) + else: + payload = DPTArray(attr_payload) + + for address in attr_address: + telegram = Telegram( + destination_address=parse_device_group_address(address), + payload=GroupValueResponse(payload) + if attr_response + else GroupValueWrite(payload), + source_address=knx_module.xknx.current_address, + ) + await knx_module.xknx.telegrams.put(telegram) + + +SERVICE_KNX_READ_SCHEMA = vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ) + } +) + + +async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for sending a GroupValueRead telegram to the KNX bus.""" + knx_module = get_knx_module(hass) + + for address in call.data[KNX_ADDRESS]: + telegram = Telegram( + destination_address=parse_device_group_address(address), + payload=GroupValueRead(), + source_address=knx_module.xknx.current_address, + ) + await knx_module.xknx.telegrams.put(telegram) + + +async def service_reload_integration(hass: HomeAssistant, call: ServiceCall) -> None: + """Reload the integration.""" + knx_module = get_knx_module(hass) + await hass.config_entries.async_reload(knx_module.entry.entry_id) + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 89f0a992ff1..bca1c7f6f0e 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -404,7 +404,7 @@ class KodiEntity(MediaPlayerEntity): # If Home Assistant is already in a running state, start the watchdog # immediately, else trigger it after Home Assistant has finished starting. - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await start_watchdog() else: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_watchdog) diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index adb1bfb6f09..c3228e1d449 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -158,7 +158,7 @@ class DataUpdateCoordinatorMixin: return True -class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module """Base implementation of DataUpdateCoordinator for Plenticore data.""" def __init__( @@ -198,7 +198,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for process data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: @@ -222,7 +222,7 @@ class ProcessDataUpdateCoordinator( class SettingDataUpdateCoordinator( PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], DataUpdateCoordinatorMixin, -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: @@ -237,7 +237,7 @@ class SettingDataUpdateCoordinator( return fetched_data -class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module """Base implementation of DataUpdateCoordinator for Plenticore data.""" def __init__( @@ -284,7 +284,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): class SelectDataUpdateCoordinator( PlenticoreSelectUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin, -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for select data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 111d497b128..237a50f85b7 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -82,6 +82,7 @@ SENSOR_PROCESS_DATA = [ name="Home Power from Battery", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), PlenticoreSensorEntityDescription( @@ -232,7 +233,7 @@ SENSOR_PROCESS_DATA = [ key="Cycles", name="Battery Cycles", icon="mdi:recycle", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_round", ), PlenticoreSensorEntityDescription( @@ -324,6 +325,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -332,6 +334,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -340,6 +343,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -357,6 +361,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -365,6 +370,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -373,6 +379,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -390,6 +397,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -398,6 +406,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -406,6 +415,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -423,6 +433,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -431,6 +442,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -439,6 +451,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -456,6 +469,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -464,6 +478,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -472,6 +487,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -489,6 +505,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -497,6 +514,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -505,6 +523,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -522,6 +541,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -530,6 +550,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -538,6 +559,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -556,6 +578,7 @@ SENSOR_PROCESS_DATA = [ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=True, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -564,6 +587,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Yield Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -572,6 +596,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Yield Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -589,6 +614,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -597,6 +623,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -605,6 +632,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -622,6 +650,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -630,6 +659,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -638,6 +668,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -655,6 +686,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -663,6 +695,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -671,6 +704,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -688,6 +722,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -696,6 +731,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -704,6 +740,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..0adfc4bebfe --- /dev/null +++ b/homeassistant/components/lamarzocco/__init__.py @@ -0,0 +1,42 @@ +"""The La Marzocco integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up La Marzocco as config entry.""" + + coordinator = LaMarzoccoUpdateCoordinator(hass) + + 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.""" + + 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/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py new file mode 100644 index 00000000000..0eb28fa9558 --- /dev/null +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +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 LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoBinarySensorEntityDescription( + LaMarzoccoEntityDescription, + BinarySensorEntityDescription, +): + """Description of a La Marzocco binary sensor.""" + + is_on_fn: Callable[[LaMarzoccoClient], bool] + + +ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( + LaMarzoccoBinarySensorEntityDescription( + key="water_tank", + translation_key="water_tank", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=lambda lm: not lm.current_status.get("water_reservoir_contact"), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda coordinator: coordinator.local_connection_configured, + ), + LaMarzoccoBinarySensorEntityDescription( + key="brew_active", + translation_key="brew_active", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda lm: bool(lm.current_status.get("brew_active")), + available_fn=lambda lm: lm.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoBinarySensorEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): + """Binary Sensor representing espresso machine water reservoir status.""" + + entity_description: LaMarzoccoBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py new file mode 100644 index 00000000000..68bae5feeb9 --- /dev/null +++ b/homeassistant/components/lamarzocco/button.py @@ -0,0 +1,59 @@ +"""Button platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient + +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 .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoButtonEntityDescription( + LaMarzoccoEntityDescription, + ButtonEntityDescription, +): + """Description of a La Marzocco button.""" + + press_fn: Callable[[LaMarzoccoClient], Coroutine[Any, Any, None]] + + +ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( + LaMarzoccoButtonEntityDescription( + key="start_backflush", + translation_key="start_backflush", + press_fn=lambda lm: lm.start_backflush(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoButtonEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): + """La Marzocco Button Entity.""" + + entity_description: LaMarzoccoButtonEntityDescription + + async def async_press(self) -> None: + """Press button.""" + await self.entity_description.press_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py new file mode 100644 index 00000000000..7c63532104f --- /dev/null +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for La Marzocco integration.""" +from collections.abc import Mapping +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_MACHINE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for La Marzocco.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + + self.reauth_entry: ConfigEntry | None = None + self._config: dict[str, Any] = {} + self._machines: list[tuple[str, str]] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + errors = {} + + if user_input: + data: dict[str, Any] = {} + if self.reauth_entry: + data = dict(self.reauth_entry.data) + data = { + **data, + **user_input, + } + + lm = LaMarzoccoClient() + try: + self._machines = await lm.get_all_machines(data) + except AuthFail: + _LOGGER.debug("Server rejected login credentials") + errors["base"] = "invalid_auth" + except RequestNotSuccessful as exc: + _LOGGER.error("Error connecting to server: %s", exc) + errors["base"] = "cannot_connect" + else: + if not self._machines: + errors["base"] = "no_machines" + + if not errors: + if self.reauth_entry: + 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") + + if not errors: + self._config = data + return await self.async_step_machine_selection() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_machine_selection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Let user select machine to connect to.""" + errors: dict[str, str] = {} + if user_input: + serial_number = user_input[CONF_MACHINE] + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + + # validate local connection if host is provided + if user_input.get(CONF_HOST): + lm = LaMarzoccoClient() + if not await lm.check_local_connection( + credentials=self._config, + host=user_input[CONF_HOST], + serial=serial_number, + ): + errors[CONF_HOST] = "cannot_connect" + + if not errors: + return self.async_create_entry( + title=serial_number, + data=self._config | user_input, + ) + + machine_options = [ + SelectOptionDict( + value=serial_number, + label=f"{model_name} ({serial_number})", + ) + for serial_number, model_name in self._machines + ] + + machine_selection_schema = vol.Schema( + { + vol.Required( + CONF_MACHINE, default=machine_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=machine_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_HOST): cv.string, + } + ) + + return self.async_show_form( + step_id="machine_selection", + data_schema=machine_selection_schema, + 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 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.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + ) + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py new file mode 100644 index 00000000000..2afd1c4cf48 --- /dev/null +++ b/homeassistant/components/lamarzocco/const.py @@ -0,0 +1,7 @@ +"""Constants for the La Marzocco integration.""" + +from typing import Final + +DOMAIN: Final = "lamarzocco" + +CONF_MACHINE: Final = "machine" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py new file mode 100644 index 00000000000..438c4e42634 --- /dev/null +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -0,0 +1,99 @@ +"""Coordinator for La Marzocco API.""" +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +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.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_MACHINE, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to handle fetching data from the La Marzocco API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.lm = LaMarzoccoClient( + callback_websocket_notify=self.async_update_listeners, + ) + self.local_connection_configured = ( + self.config_entry.data.get(CONF_HOST) is not None + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + if not self.lm.initialized: + await self._async_init_client() + + await self._async_handle_request( + self.lm.update_local_machine_status, force_update=True + ) + + _LOGGER.debug("Current status: %s", str(self.lm.current_status)) + + async def _async_init_client(self) -> None: + """Initialize the La Marzocco Client.""" + + # Initialize cloud API + _LOGGER.debug("Initializing Cloud API") + await self._async_handle_request( + self.lm.init_cloud_api, + credentials=self.config_entry.data, + machine_serial=self.config_entry.data[CONF_MACHINE], + ) + _LOGGER.debug("Model name: %s", self.lm.model_name) + + # initialize local API + if (host := self.config_entry.data.get(CONF_HOST)) is not None: + _LOGGER.debug("Initializing local API") + await self.lm.init_local_api( + host=host, + client=get_async_client(self.hass), + ) + + _LOGGER.debug("Init WebSocket in Background Task") + + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.lm.lm_local_api.websocket_connect( + callback=self.lm.on_websocket_message_received, + use_sigterm_handler=False, + ), + name="lm_websocket_task", + ) + + self.lm.initialized = True + + async def _async_handle_request( + self, + func: Callable[..., Coroutine[None, None, None]], + *args: Any, + **kwargs: Any, + ) -> None: + """Handle a request to the API.""" + try: + await func(*args, **kwargs) + except AuthFail as ex: + msg = "Authentication failed." + _LOGGER.debug(msg, exc_info=True) + raise ConfigEntryAuthFailed(msg) from ex + except RequestNotSuccessful as ex: + _LOGGER.debug(ex, exc_info=True) + raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py new file mode 100644 index 00000000000..6e75152bd60 --- /dev/null +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -0,0 +1,44 @@ +"""Diagnostics support for La Marzocco.""" + + +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 LaMarzoccoUpdateCoordinator + +TO_REDACT = { + "serial_number", + "machine_sn", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaMarzoccoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # collect all data sources + data = {} + data["current_status"] = coordinator.lm.current_status + data["machine_info"] = coordinator.lm.machine_info + data["config"] = coordinator.lm.config + data["statistics"] = {"stats": coordinator.lm.statistics} # wrap to satisfy mypy + + # build a firmware section + data["firmware"] = { + "machine": { + "version": coordinator.lm.firmware_version, + "latest_version": coordinator.lm.latest_firmware_version, + }, + "gateway": { + "version": coordinator.lm.gateway_version, + "latest_version": coordinator.lm.latest_gateway_version, + }, + } + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py new file mode 100644 index 00000000000..6918741f1d3 --- /dev/null +++ b/homeassistant/components/lamarzocco/entity.py @@ -0,0 +1,54 @@ +"""Base class for the La Marzocco entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +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 LaMarzoccoUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoEntityDescription(EntityDescription): + """Description for all LM entities.""" + + available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True + supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True + + +class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): + """Common elements for all entities.""" + + entity_description: LaMarzoccoEntityDescription + _attr_has_entity_name = True + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.lm + ) + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + lm = coordinator.lm + self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lm.serial_number)}, + name=lm.machine_name, + manufacturer="La Marzocco", + model=lm.true_model_name, + serial_number=lm.serial_number, + sw_version=lm.firmware_version, + ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json new file mode 100644 index 00000000000..70adfe95134 --- /dev/null +++ b/homeassistant/components/lamarzocco/icons.json @@ -0,0 +1,102 @@ +{ + "entity": { + "binary_sensor": { + "water_tank": { + "default": "mdi:water", + "state": { + "on": "mdi:water-alert", + "off": "mdi:water-check" + } + }, + "brew_active": { + "default": "mdi:cup", + "state": { + "on": "mdi:cup-water", + "off": "mdi:cup-off" + } + } + }, + "button": { + "start_backflush": { + "default": "mdi:water-sync" + } + }, + "number": { + "coffee_temp": { + "default": "mdi:thermometer-water" + }, + "steam_temp": { + "default": "mdi:thermometer-water" + }, + "tea_water_duration": { + "default": "mdi:timer-sand" + } + }, + "select": { + "steam_temp_select": { + "default": "mdi:thermometer", + "state": { + "1": "mdi:thermometer-low", + "2": "mdi:thermometer", + "3": "mdi:thermometer-high" + } + }, + "prebrew_infusion_select": { + "default": "mdi:water-pump-off", + "state": { + "disabled": "mdi:water-pump-off", + "prebrew": "mdi:water-pump", + "preinfusion": "mdi:water-pump" + } + } + }, + "sensor": { + "drink_stats_coffee": { + "default": "mdi:chart-line" + }, + "drink_stats_flushing": { + "default": "mdi:chart-line" + }, + "shot_timer": { + "default": "mdi:timer" + }, + "current_temp_coffee": { + "default": "mdi:thermometer" + }, + "current_temp_steam": { + "default": "mdi:thermometer" + } + }, + "switch": { + "main": { + "default": "mdi:power", + "state": { + "on": "mdi:power", + "off": "mdi:power-off" + } + }, + "auto_on_off": { + "default": "mdi:alarm", + "state": { + "on": "mdi:alarm", + "off": "mdi:alarm-off" + } + }, + "steam_boiler": { + "default": "mdi:water-boiler", + "state": { + "on": "mdi:water-boiler", + "off": "mdi:water-boiler-off" + } + } + }, + "update": { + "machine_firmware": { + "default": "mdi:cloud-download" + }, + "gateway_firmware": { + "default": "mdi:cloud-download" + } + } + } +} diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json new file mode 100644 index 00000000000..8dd8e1294b0 --- /dev/null +++ b/homeassistant/components/lamarzocco/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lamarzocco", + "name": "La Marzocco", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lamarzocco", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["lmcloud"], + "requirements": ["lmcloud==0.4.35"] +} diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py new file mode 100644 index 00000000000..bf866872f5b --- /dev/null +++ b/homeassistant/components/lamarzocco/number.py @@ -0,0 +1,120 @@ +"""Number platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoModel + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoNumberEntityDescription( + LaMarzoccoEntityDescription, + NumberEntityDescription, +): + """Description of a La Marzocco number entity.""" + + native_value_fn: Callable[[LaMarzoccoClient], float | int] + set_value_fn: Callable[ + [LaMarzoccoUpdateCoordinator, float | int], Coroutine[Any, Any, bool] + ] + + +ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( + LaMarzoccoNumberEntityDescription( + key="coffee_temp", + translation_key="coffee_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_step=PRECISION_TENTHS, + native_min_value=85, + native_max_value=104, + set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp(temp), + native_value_fn=lambda lm: lm.current_status["coffee_set_temp"], + ), + LaMarzoccoNumberEntityDescription( + key="steam_temp", + translation_key="steam_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_step=PRECISION_WHOLE, + native_min_value=126, + native_max_value=131, + set_value_fn=lambda coordinator, temp: coordinator.lm.set_steam_temp(int(temp)), + native_value_fn=lambda lm: lm.current_status["steam_set_temp"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), + LaMarzoccoNumberEntityDescription( + key="tea_water_duration", + translation_key="tea_water_duration", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=30, + set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( + value=int(value) + ), + native_value_fn=lambda lm: lm.current_status["dose_hot_water"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoNumberEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): + """La Marzocco number entity.""" + + entity_description: LaMarzoccoNumberEntityDescription + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn(self.coordinator.lm) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn(self.coordinator, value) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py new file mode 100644 index 00000000000..1e70000a479 --- /dev/null +++ b/homeassistant/components/lamarzocco/select.py @@ -0,0 +1,90 @@ +"""Select platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoModel + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSelectEntityDescription( + LaMarzoccoEntityDescription, + SelectEntityDescription, +): + """Description of a La Marzocco select entity.""" + + current_option_fn: Callable[[LaMarzoccoClient], str] + select_option_fn: Callable[ + [LaMarzoccoUpdateCoordinator, str], Coroutine[Any, Any, bool] + ] + + +ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( + LaMarzoccoSelectEntityDescription( + key="steam_temp_select", + translation_key="steam_temp_select", + options=["1", "2", "3"], + select_option_fn=lambda coordinator, option: coordinator.lm.set_steam_level( + int(option) + ), + current_option_fn=lambda lm: lm.current_status["steam_level_set"], + supported_fn=lambda coordinator: coordinator.lm.model_name + == LaMarzoccoModel.LINEA_MICRA, + ), + LaMarzoccoSelectEntityDescription( + key="prebrew_infusion_select", + translation_key="prebrew_infusion_select", + options=["disabled", "prebrew", "preinfusion"], + select_option_fn=lambda coordinator, + option: coordinator.lm.select_pre_brew_infusion_mode(option.capitalize()), + current_option_fn=lambda lm: lm.pre_brew_infusion_mode.lower(), + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.LINEA_MICRA, + LaMarzoccoModel.LINEA_MINI, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoSelectEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): + """La Marzocco select entity.""" + + entity_description: LaMarzoccoSelectEntityDescription + + @property + def current_option(self) -> str: + """Return the current selected option.""" + return str(self.entity_description.current_option_fn(self.coordinator.lm)) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_option_fn(self.coordinator, option) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py new file mode 100644 index 00000000000..ea5a5e184e1 --- /dev/null +++ b/homeassistant/components/lamarzocco/sensor.py @@ -0,0 +1,105 @@ +"""Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSensorEntityDescription( + LaMarzoccoEntityDescription, + SensorEntityDescription, +): + """Description of a La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoClient], float | int] + + +ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="drink_stats_coffee", + translation_key="drink_stats_coffee", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("drinks_k1", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="drink_stats_flushing", + translation_key="drink_stats_flushing", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("total_flushing", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="shot_timer", + translation_key="shot_timer", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda lm: lm.current_status.get("brew_active_duration", 0), + available_fn=lambda lm: lm.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda coordinator: coordinator.local_connection_configured, + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_coffee", + translation_key="current_temp_coffee", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_steam", + translation_key="current_temp_steam", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("steam_temp", 0), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoSensorEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor representing espresso machine temperature data.""" + + entity_description: LaMarzoccoSensorEntityDescription + + @property + def native_value(self) -> int | float: + """State of the sensor.""" + return self.entity_description.value_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json new file mode 100644 index 00000000000..7537405c6cd --- /dev/null +++ b/homeassistant/components/lamarzocco/strings.json @@ -0,0 +1,122 @@ +{ + "config": { + "flow_title": "La Marzocco Espresso {host}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_machines": "No machines found in account", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your username from the La Marzocco app", + "password": "Your password from the La Marzocco app" + } + }, + "machine_selection": { + "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "machine": "Machine" + }, + "data_description": { + "host": "Local IP address of the machine" + } + }, + "reauth_confirm": { + "description": "Re-authentication required. Please enter your password again.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" + } + } + } + }, + "entity": { + "binary_sensor": { + "brew_active": { + "name": "Brewing active" + }, + "water_tank": { + "name": "Water tank empty" + } + }, + "button": { + "start_backflush": { + "name": "Start backflush" + } + }, + "number": { + "coffee_temp": { + "name": "Coffee target temperature" + }, + "steam_temp": { + "name": "Steam target temperature" + }, + "tea_water_duration": { + "name": "Tea water duration" + } + }, + "select": { + "prebrew_infusion_select": { + "name": "Prebrew/-infusion mode", + "state": { + "disabled": "Disabled", + "prebrew": "Prebrew", + "preinfusion": "Preinfusion" + } + }, + "steam_temp_select": { + "name": "Steam level", + "state": { + "1": "1", + "2": "2", + "3": "3" + } + } + }, + "sensor": { + "current_temp_coffee": { + "name": "Current coffee temperature" + }, + "current_temp_steam": { + "name": "Current steam temperature" + }, + "drink_stats_coffee": { + "name": "Total coffees made" + }, + "drink_stats_flushing": { + "name": "Total flushes made" + }, + "shot_timer": { + "name": "Shot timer" + } + }, + "switch": { + "auto_on_off": { + "name": "Auto on/off" + }, + "steam_boiler": { + "name": "Steam boiler" + } + }, + "update": { + "machine_firmware": { + "name": "Machine firmware" + }, + "gateway_firmware": { + "name": "Gateway firmware" + } + } + } +} diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py new file mode 100644 index 00000000000..0d4d8d7dc8e --- /dev/null +++ b/homeassistant/components/lamarzocco/switch.py @@ -0,0 +1,90 @@ +"""Switch platform for La Marzocco espresso machines.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSwitchEntityDescription( + LaMarzoccoEntityDescription, + SwitchEntityDescription, +): + """Description of a La Marzocco Switch.""" + + control_fn: Callable[[LaMarzoccoUpdateCoordinator, bool], Coroutine[Any, Any, bool]] + is_on_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] + + +ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( + LaMarzoccoSwitchEntityDescription( + key="main", + translation_key="main", + name=None, + control_fn=lambda coordinator, state: coordinator.lm.set_power(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], + ), + LaMarzoccoSwitchEntityDescription( + key="auto_on_off", + translation_key="auto_on_off", + control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global( + state + ), + is_on_fn=lambda coordinator: coordinator.lm.current_status["global_auto"] + == "Enabled", + entity_category=EntityCategory.CONFIG, + ), + LaMarzoccoSwitchEntityDescription( + key="steam_boiler_enable", + translation_key="steam_boiler", + control_fn=lambda coordinator, state: coordinator.lm.set_steam(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status[ + "steam_boiler_enable" + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities and services.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoSwitchEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): + """Switches representing espresso machine power, prebrew, and auto on/off.""" + + entity_description: LaMarzoccoSwitchEntityDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self.entity_description.control_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + await self.entity_description.control_fn(self.coordinator, False) + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self.entity_description.is_on_fn(self.coordinator) diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py new file mode 100644 index 00000000000..cc3e665725b --- /dev/null +++ b/homeassistant/components/lamarzocco/update.py @@ -0,0 +1,103 @@ +"""Support for La Marzocco update entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoUpdateableComponent + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +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 .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoUpdateEntityDescription( + LaMarzoccoEntityDescription, + UpdateEntityDescription, +): + """Description of a La Marzocco update entities.""" + + current_fw_fn: Callable[[LaMarzoccoClient], str] + latest_fw_fn: Callable[[LaMarzoccoClient], str] + component: LaMarzoccoUpdateableComponent + + +ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( + LaMarzoccoUpdateEntityDescription( + key="machine_firmware", + translation_key="machine_firmware", + device_class=UpdateDeviceClass.FIRMWARE, + current_fw_fn=lambda lm: lm.firmware_version, + latest_fw_fn=lambda lm: lm.latest_firmware_version, + component=LaMarzoccoUpdateableComponent.MACHINE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoUpdateEntityDescription( + key="gateway_firmware", + translation_key="gateway_firmware", + device_class=UpdateDeviceClass.FIRMWARE, + current_fw_fn=lambda lm: lm.gateway_version, + latest_fw_fn=lambda lm: lm.latest_gateway_version, + component=LaMarzoccoUpdateableComponent.GATEWAY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create update entities.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoUpdateEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): + """Entity representing the update state.""" + + entity_description: LaMarzoccoUpdateEntityDescription + _attr_supported_features = UpdateEntityFeature.INSTALL + + @property + def installed_version(self) -> str | None: + """Return the current firmware version.""" + return self.entity_description.current_fw_fn(self.coordinator.lm) + + @property + def latest_version(self) -> str: + """Return the latest firmware version.""" + return self.entity_description.latest_fw_fn(self.coordinator.lm) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() + success = await self.coordinator.lm.update_firmware( + self.entity_description.component + ) + if not success: + raise HomeAssistantError("Update failed") + self._attr_in_progress = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lawn_mower/icons.json b/homeassistant/components/lawn_mower/icons.json new file mode 100644 index 00000000000..b25bf927fcd --- /dev/null +++ b/homeassistant/components/lawn_mower/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot-mower" + } + }, + "services": { + "dock": "mdi:home-import-outline", + "pause": "mdi:pause", + "start_mowing": "mdi:play" + } +} diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 4f40bcd25cd..d1e92d54fb1 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -69,7 +69,7 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -95,6 +95,11 @@ class LcnClimate(LcnEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.HEAT] if self.is_lockable: self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/homeassistant/components/leaone/__init__.py b/homeassistant/components/leaone/__init__.py new file mode 100644 index 00000000000..9f8bac34d55 --- /dev/null +++ b/homeassistant/components/leaone/__init__.py @@ -0,0 +1,49 @@ +"""The Leaone integration.""" +from __future__ import annotations + +import logging + +from leaone_ble import LeaoneBluetoothDeviceData + +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Leaone BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = LeaoneBluetoothDeviceData() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + coordinator.async_start() + ) # only start after all platforms have had a chance to subscribe + 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/leaone/config_flow.py b/homeassistant/components/leaone/config_flow.py new file mode 100644 index 00000000000..5bbf2917332 --- /dev/null +++ b/homeassistant/components/leaone/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for Leaone integration.""" +from __future__ import annotations + +from typing import Any + +from leaone_ble import LeaoneBluetoothDeviceData as DeviceData +import voluptuous as vol + +from homeassistant.components.bluetooth import async_discovered_service_info +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class LeaoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for leaone.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = DeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/leaone/const.py b/homeassistant/components/leaone/const.py new file mode 100644 index 00000000000..6556e66e4b4 --- /dev/null +++ b/homeassistant/components/leaone/const.py @@ -0,0 +1,3 @@ +"""Constants for the Leaone integration.""" + +DOMAIN = "leaone" diff --git a/homeassistant/components/leaone/device.py b/homeassistant/components/leaone/device.py new file mode 100644 index 00000000000..a745873b693 --- /dev/null +++ b/homeassistant/components/leaone/device.py @@ -0,0 +1,15 @@ +"""Support for Leaone devices.""" +from __future__ import annotations + +from leaone_ble import DeviceKey + +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothEntityKey, +) + + +def device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) diff --git a/homeassistant/components/leaone/manifest.json b/homeassistant/components/leaone/manifest.json new file mode 100644 index 00000000000..97ac8a06e97 --- /dev/null +++ b/homeassistant/components/leaone/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "leaone", + "name": "LeaOne", + "codeowners": ["@bdraco"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/leaone", + "iot_class": "local_push", + "requirements": ["leaone-ble==0.1.0"] +} diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py new file mode 100644 index 00000000000..a614e63231a --- /dev/null +++ b/homeassistant/components/leaone/sensor.py @@ -0,0 +1,152 @@ +"""Support for Leaone sensors.""" +from __future__ import annotations + +from leaone_ble import DeviceClass as LeaoneSensorDeviceClass, SensorUpdate, Units + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfMass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +from .const import DOMAIN +from .device import device_key_to_bluetooth_entity_key + +SENSOR_DESCRIPTIONS = { + ( + LeaoneSensorDeviceClass.MASS_NON_STABILIZED, + Units.MASS_KILOGRAMS, + ): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.MASS_NON_STABILIZED}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + (LeaoneSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + (LeaoneSensorDeviceClass.IMPEDANCE, Units.OHM): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.IMPEDANCE}_{Units.OHM}", + icon="mdi:omega", + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ( + LeaoneSensorDeviceClass.SIGNAL_STRENGTH, + Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ( + LeaoneSensorDeviceClass.PACKET_ID, + None, + ): SensorEntityDescription( + key=str(LeaoneSensorDeviceClass.PACKET_ID), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +} + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class and description.native_unit_of_measurement + }, + entity_data={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Leaone BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + LeaoneBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) + + +class LeaoneBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + SensorEntity, +): + """Representation of a Leaone sensor.""" + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json new file mode 100644 index 00000000000..6391c754dec --- /dev/null +++ b/homeassistant/components/leaone/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 1bdb8bf8ec9..70b77ba6787 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_update(): + async def _async_update() -> None: """Update the device state.""" try: await led_ble.update() diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 5fba73ef808..a1da82dfe6d 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -38,7 +38,7 @@ async def async_setup_entry( async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)]) -class LEDBLEEntity(CoordinatorEntity, LightEntity): +class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): """Representation of LEDBLE device.""" _attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE} @@ -47,7 +47,7 @@ class LEDBLEEntity(CoordinatorEntity, LightEntity): _attr_supported_features = LightEntityFeature.EFFECT def __init__( - self, coordinator: DataUpdateCoordinator, device: LEDBLE, name: str + self, coordinator: DataUpdateCoordinator[None], device: LEDBLE, name: str ) -> None: """Initialize an ledble light.""" super().__init__(coordinator) diff --git a/homeassistant/components/led_ble/models.py b/homeassistant/components/led_ble/models.py index 611d484ea61..0eda9439f11 100644 --- a/homeassistant/components/led_ble/models.py +++ b/homeassistant/components/led_ble/models.py @@ -14,4 +14,4 @@ class LEDBLEData: title: str device: LEDBLE - coordinator: DataUpdateCoordinator + coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index c6e0fad14c6..5c2d62545d6 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -2,186 +2,35 @@ 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.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import issue_registry as ir -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 .coordinator import Life360DataUpdateCoordinator, MissingLocReason - -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] - -CONF_ACCOUNTS = "accounts" - -SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] +DOMAIN = "life360" -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 - ) - # member_id: missing location reason - missing_loc_reason: dict[str, MissingLocReason] = field( - init=False, default_factory=dict - ) - # member_id: ConfigEntry.entry_id - tracked_members: dict[str, str] = field(init=False, default_factory=dict) - 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: +async def async_setup_entry(hass: HomeAssistant, _: 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. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/life360" + }, + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" - - # Unload components for our platforms. - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN].coordinators[entry.entry_id] - # Remove any members that were tracked by this entry. - for member_id, entry_id in hass.data[DOMAIN].tracked_members.copy().items(): - if entry_id == entry.entry_id: - del hass.data[DOMAIN].tracked_members[member_id] - - return unload_ok + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + return True diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py deleted file mode 100644 index 07ef4d06ed9..00000000000 --- a/homeassistant/components/life360/button.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Support for Life360 buttons.""" -from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import Life360DataUpdateCoordinator -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Life360 buttons.""" - coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ - config_entry.entry_id - ] - async_add_entities( - Life360UpdateLocationButton(coordinator, member.circle_id, member_id) - for member_id, member in coordinator.data.members.items() - ) - - -class Life360UpdateLocationButton( - CoordinatorEntity[Life360DataUpdateCoordinator], ButtonEntity -): - """Represent an Life360 Update Location button.""" - - _attr_has_entity_name = True - _attr_translation_key = "update_location" - - def __init__( - self, - coordinator: Life360DataUpdateCoordinator, - circle_id: str, - member_id: str, - ) -> None: - """Initialize a new Life360 Update Location button.""" - super().__init__(coordinator) - self._circle_id = circle_id - self._member_id = member_id - self._attr_unique_id = f"{member_id}-update-location" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, member_id)}, - name=coordinator.data.members[member_id].name, - ) - - async def async_press(self) -> None: - """Handle the button press.""" - await self.coordinator.update_location(self._circle_id, self._member_id) diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 4b59bcadf88..ea9f33d9f45 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -2,205 +2,12 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any, cast +from homeassistant.config_entries import ConfigFlow -from life360 import Life360, Life360Error, LoginError -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback -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 ( - COMM_TIMEOUT, - CONF_AUTHORIZATION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DEFAULT_OPTIONS, - DOMAIN, - LOGGER, - OPTIONS, - SHOW_DRIVING, -) - -LIMIT_GPS_ACC = "limit_gps_acc" -SET_DRIVE_SPEED = "set_drive_speed" - - -def account_schema( - def_username: str | vol.UNDEFINED = vol.UNDEFINED, - def_password: str | vol.UNDEFINED = vol.UNDEFINED, -) -> dict[vol.Marker, Any]: - """Return schema for an account with optional default values.""" - return { - vol.Required(CONF_USERNAME, default=def_username): cv.string, - vol.Required(CONF_PASSWORD, default=def_password): cv.string, - } - - -def password_schema( - def_password: str | vol.UNDEFINED = vol.UNDEFINED, -) -> dict[vol.Marker, Any]: - """Return schema for a password with optional default value.""" - return {vol.Required(CONF_PASSWORD, default=def_password): cv.string} +from . import DOMAIN class Life360ConfigFlow(ConfigFlow, domain=DOMAIN): """Life360 integration config flow.""" VERSION = 1 - _api: Life360 | None = None - _username: str | vol.UNDEFINED = vol.UNDEFINED - _password: str | vol.UNDEFINED = vol.UNDEFINED - _reauth_entry: ConfigEntry | None = None - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> Life360OptionsFlow: - """Get the options flow for this handler.""" - return Life360OptionsFlow(config_entry) - - async def _async_verify(self, step_id: str) -> FlowResult: - """Attempt to authorize the provided credentials.""" - if not self._api: - self._api = Life360( - session=async_get_clientsession(self.hass), timeout=COMM_TIMEOUT - ) - errors: dict[str, str] = {} - try: - authorization = await self._api.get_authorization( - self._username, self._password - ) - except LoginError as exc: - LOGGER.debug("Login error: %s", exc) - errors["base"] = "invalid_auth" - except Life360Error as exc: - LOGGER.debug("Unexpected error communicating with Life360 server: %s", exc) - errors["base"] = "cannot_connect" - if errors: - if step_id == "user": - schema = account_schema(self._username, self._password) - else: - schema = password_schema(self._password) - return self.async_show_form( - step_id=step_id, data_schema=vol.Schema(schema), errors=errors - ) - - data = { - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_AUTHORIZATION: authorization, - } - - if self._reauth_entry: - LOGGER.debug("Reauthorization successful") - 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") - - return self.async_create_entry( - title=cast(str, self.unique_id), data=data, options=DEFAULT_OPTIONS - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a config flow initiated by the user.""" - if not user_input: - return self.async_show_form( - step_id="user", data_schema=vol.Schema(account_schema()) - ) - - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - - await self.async_set_unique_id(self._username.lower()) - self._abort_if_unique_id_configured() - - return await self._async_verify("user") - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Handle reauthorization.""" - self._username = data[CONF_USERNAME] - self._reauth_entry = self.hass.config_entries.async_get_entry( - self.context["entry_id"] - ) - # Always start with current credentials since they may still be valid and a - # simple reauthorization will be successful. - return await self.async_step_reauth_confirm(dict(data)) - - async def async_step_reauth_confirm(self, user_input: dict[str, Any]) -> FlowResult: - """Handle reauthorization completion.""" - if not user_input: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema(password_schema(self._password)), - errors={"base": "invalid_auth"}, - ) - self._password = user_input[CONF_PASSWORD] - return await self._async_verify("reauth_confirm") - - -class Life360OptionsFlow(OptionsFlow): - """Life360 integration options flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle account options.""" - options = self.config_entry.options - - if user_input is not None: - new_options = _extract_account_options(user_input) - return self.async_create_entry(title="", data=new_options) - - return self.async_show_form( - step_id="init", data_schema=vol.Schema(_account_options_schema(options)) - ) - - -def _account_options_schema(options: Mapping[str, Any]) -> dict[vol.Marker, Any]: - """Create schema for account options form.""" - def_limit_gps_acc = options[CONF_MAX_GPS_ACCURACY] is not None - def_max_gps = options[CONF_MAX_GPS_ACCURACY] or vol.UNDEFINED - def_set_drive_speed = options[CONF_DRIVING_SPEED] is not None - def_speed = options[CONF_DRIVING_SPEED] or vol.UNDEFINED - def_show_driving = options[SHOW_DRIVING] - - return { - vol.Required(LIMIT_GPS_ACC, default=def_limit_gps_acc): bool, - vol.Optional(CONF_MAX_GPS_ACCURACY, default=def_max_gps): vol.Coerce(float), - vol.Required(SET_DRIVE_SPEED, default=def_set_drive_speed): bool, - vol.Optional(CONF_DRIVING_SPEED, default=def_speed): vol.Coerce(float), - vol.Optional(SHOW_DRIVING, default=def_show_driving): bool, - } - - -def _extract_account_options(user_input: dict) -> dict[str, Any]: - """Remove options from user input and return as a separate dict.""" - result = {} - - for key in OPTIONS: - value = user_input.pop(key, None) - # Was "include" checkbox (if there was one) corresponding to option key True - # (meaning option should be included)? - incl = user_input.pop( - { - CONF_MAX_GPS_ACCURACY: LIMIT_GPS_ACC, - CONF_DRIVING_SPEED: SET_DRIVE_SPEED, - }.get(key), - True, - ) - result[key] = value if incl else None - - return result diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py deleted file mode 100644 index 333ce14fbf6..00000000000 --- a/homeassistant/components/life360/const.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Constants for Life360 integration.""" - -from datetime import timedelta -import logging - -from aiohttp import ClientTimeout - -DOMAIN = "life360" -LOGGER = logging.getLogger(__package__) - -ATTRIBUTION = "Data provided by life360.com" -COMM_MAX_RETRIES = 3 -COMM_TIMEOUT = ClientTimeout(sock_connect=15, total=60) -SPEED_FACTOR_MPH = 2.25 -SPEED_DIGITS = 1 -UPDATE_INTERVAL = timedelta(seconds=10) - -ATTR_ADDRESS = "address" -ATTR_AT_LOC_SINCE = "at_loc_since" -ATTR_DRIVING = "driving" -ATTR_LAST_SEEN = "last_seen" -ATTR_PLACE = "place" -ATTR_SPEED = "speed" -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, - CONF_MAX_GPS_ACCURACY: None, - SHOW_DRIVING: False, -} -OPTIONS = list(DEFAULT_OPTIONS.keys()) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py deleted file mode 100644 index 4ef6e20d703..00000000000 --- a/homeassistant/components/life360/coordinator.py +++ /dev/null @@ -1,246 +0,0 @@ -"""DataUpdateCoordinator for the Life360 integration.""" - -from __future__ import annotations - -import asyncio -from contextlib import suppress -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from typing import Any - -from life360 import Life360, Life360Error, LoginError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength -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 homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM - -from .const import ( - COMM_MAX_RETRIES, - COMM_TIMEOUT, - CONF_AUTHORIZATION, - DOMAIN, - LOGGER, - SPEED_DIGITS, - SPEED_FACTOR_MPH, - UPDATE_INTERVAL, -) - - -class MissingLocReason(Enum): - """Reason member location information is missing.""" - - VAGUE_ERROR_REASON = "vague error reason" - EXPLICIT_ERROR_REASON = "explicit error reason" - - -@dataclass -class Life360Place: - """Life360 Place data.""" - - name: str - latitude: float - longitude: float - radius: float - - -@dataclass -class Life360Circle: - """Life360 Circle data.""" - - name: str - places: dict[str, Life360Place] - - -@dataclass -class Life360Member: - """Life360 Member data.""" - - address: str | None - at_loc_since: datetime - battery_charging: bool - battery_level: int - circle_id: str - driving: bool - entity_picture: str - gps_accuracy: int - last_seen: datetime - latitude: float - longitude: float - name: str - place: str | None - speed: float - wifi_on: bool - - -@dataclass -class Life360Data: - """Life360 data.""" - - circles: dict[str, Life360Circle] = field(init=False, default_factory=dict) - members: dict[str, Life360Member] = field(init=False, default_factory=dict) - - -class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): - """Life360 data update coordinator.""" - - config_entry: ConfigEntry - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize data update coordinator.""" - super().__init__( - hass, - LOGGER, - name=f"{DOMAIN} ({entry.unique_id})", - update_interval=UPDATE_INTERVAL, - ) - self._hass = hass - self._api = Life360( - session=async_get_clientsession(hass), - timeout=COMM_TIMEOUT, - max_retries=COMM_MAX_RETRIES, - authorization=entry.data[CONF_AUTHORIZATION], - ) - self._missing_loc_reason = hass.data[DOMAIN].missing_loc_reason - - async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: - """Get data from Life360.""" - try: - return await getattr(self._api, func)(*args) - except LoginError as exc: - LOGGER.debug("Login error: %s", exc) - raise ConfigEntryAuthFailed(exc) from exc - except Life360Error as exc: - LOGGER.debug("%s: %s", exc.__class__.__name__, exc) - raise UpdateFailed(exc) from exc - - async def update_location(self, circle_id: str, member_id: str) -> None: - """Update location for given Circle and Member.""" - await self._retrieve_data("update_location", circle_id, member_id) - - async def _async_update_data(self) -> Life360Data: - """Get & process data from Life360.""" - - data = Life360Data() - - for circle in await self._retrieve_data("get_circles"): - circle_id = 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"], - { - place["id"]: Life360Place( - place["name"], - float(place["latitude"]), - float(place["longitude"]), - float(place["radius"]), - ) - for place in circle_places - }, - ) - - for member in circle_members: - # Member isn't sharing location. - if not int(member["features"]["shareLocation"]): - continue - - member_id = member["id"] - - first = member["firstName"] - last = member["lastName"] - if first and last: - name = " ".join([first, last]) - else: - name = first or last - - cur_missing_reason = self._missing_loc_reason.get(member_id) - - # Check if location information is missing. This can happen if server - # has not heard from member's device in a long time (e.g., has been off - # for a long time, or has lost service, etc.) - if loc := member["location"]: - with suppress(KeyError): - del self._missing_loc_reason[member_id] - else: - if explicit_reason := member["issues"]["title"]: - if extended_reason := member["issues"]["dialog"]: - explicit_reason += f": {extended_reason}" - # Note that different Circles can report missing location in - # different ways. E.g., one might report an explicit reason and - # another does not. If a vague reason has already been logged but a - # more explicit reason is now available, log that, too. - if ( - cur_missing_reason is None - or cur_missing_reason == MissingLocReason.VAGUE_ERROR_REASON - and explicit_reason - ): - if explicit_reason: - self._missing_loc_reason[ - member_id - ] = MissingLocReason.EXPLICIT_ERROR_REASON - err_msg = explicit_reason - else: - self._missing_loc_reason[ - member_id - ] = MissingLocReason.VAGUE_ERROR_REASON - err_msg = "Location information missing" - LOGGER.error("%s: %s", name, err_msg) - continue - - # Note that member may be in more than one circle. If that's the case - # just go ahead and process the newly retrieved data (overwriting the - # older data), since it might be slightly newer than what was retrieved - # while processing another circle. - - place = loc["name"] or None - - address1: str | None = loc["address1"] or None - address2: str | None = loc["address2"] or None - if address1 and address2: - address: str | None = ", ".join([address1, address2]) - else: - address = address1 or address2 - - speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH) - if self._hass.config.units is METRIC_SYSTEM: - speed = DistanceConverter.convert( - speed, UnitOfLength.MILES, UnitOfLength.KILOMETERS - ) - - data.members[member_id] = Life360Member( - address, - dt_util.utc_from_timestamp(int(loc["since"])), - bool(int(loc["charge"])), - int(float(loc["battery"])), - circle_id, - bool(int(loc["isDriving"])), - member["avatar"], - # Life360 reports accuracy in feet, but Device Tracker expects - # gps_accuracy in meters. - round( - DistanceConverter.convert( - float(loc["accuracy"]), - UnitOfLength.FEET, - UnitOfLength.METERS, - ) - ), - dt_util.utc_from_timestamp(int(loc["timestamp"])), - float(loc["latitude"]), - float(loc["longitude"]), - name, - place, - round(speed, SPEED_DIGITS), - bool(int(loc["wifiState"])), - ) - - return data diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py deleted file mode 100644 index ee097b9e989..00000000000 --- a/homeassistant/components/life360/device_tracker.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Support for Life360 device tracking.""" - -from __future__ import annotations - -from collections.abc import Mapping -from contextlib import suppress -from typing import Any, cast - -from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_BATTERY_CHARGING -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 ( - ATTR_ADDRESS, - ATTR_AT_LOC_SINCE, - ATTR_DRIVING, - ATTR_LAST_SEEN, - ATTR_PLACE, - ATTR_SPEED, - ATTR_WIFI_ON, - ATTRIBUTION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DOMAIN, - LOGGER, - SHOW_DRIVING, -) -from .coordinator import Life360DataUpdateCoordinator, Life360Member - -_LOC_ATTRS = ( - "address", - "at_loc_since", - "driving", - "gps_accuracy", - "last_seen", - "latitude", - "longitude", - "place", - "speed", -) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the device tracker platform.""" - coordinator = hass.data[DOMAIN].coordinators[entry.entry_id] - tracked_members = hass.data[DOMAIN].tracked_members - logged_circles = hass.data[DOMAIN].logged_circles - logged_places = hass.data[DOMAIN].logged_places - - @callback - def process_data(new_members_only: bool = True) -> None: - """Process new Life360 data.""" - for circle_id, circle in coordinator.data.circles.items(): - if circle_id not in logged_circles: - logged_circles.append(circle_id) - LOGGER.debug("Circle: %s", circle.name) - - new_places = [] - for place_id, place in circle.places.items(): - if place_id not in logged_places: - logged_places.append(place_id) - new_places.append(place) - if new_places: - msg = f"Places from {circle.name}:" - for place in new_places: - msg += f"\n- name: {place.name}" - msg += f"\n latitude: {place.latitude}" - msg += f"\n longitude: {place.longitude}" - msg += f"\n radius: {place.radius}" - LOGGER.debug(msg) - - new_entities = [] - for member_id, member in coordinator.data.members.items(): - tracked_by_entry = tracked_members.get(member_id) - if new_member := not tracked_by_entry: - tracked_members[member_id] = entry.entry_id - LOGGER.debug("Member: %s (%s)", member.name, entry.unique_id) - if ( - new_member - or tracked_by_entry == entry.entry_id - and not new_members_only - ): - new_entities.append(Life360DeviceTracker(coordinator, member_id)) - async_add_entities(new_entities) - - process_data(new_members_only=False) - entry.async_on_unload(coordinator.async_add_listener(process_data)) - - -class Life360DeviceTracker( - CoordinatorEntity[Life360DataUpdateCoordinator], TrackerEntity -): - """Life360 Device Tracker.""" - - _attr_attribution = ATTRIBUTION - _attr_unique_id: str - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, coordinator: Life360DataUpdateCoordinator, member_id: str - ) -> None: - """Initialize Life360 Entity.""" - super().__init__(coordinator) - self._attr_unique_id = member_id - - self._data: Life360Member | None = coordinator.data.members[member_id] - self._prev_data = self._data - - self._name = self._data.name - self._attr_entity_picture = self._data.entity_picture - - # Server sends a pair of address values on alternate updates. Keep the pair of - # values so they can be combined into the one address attribute. - # The pair will either be two different address values, or one address and a - # copy of the Place value (if the Member is in a Place.) In the latter case we - # won't duplicate the Place name, but rather just use one the address value. Use - # the value of None to hold one of the "slots" in the list so we'll know not to - # expect another address value. - if (address := self._data.address) == self._data.place: - address = None - self._addresses = [address] - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo(identifiers={(DOMAIN, self._attr_unique_id)}, name=self._name) - - @property - def _options(self) -> Mapping[str, Any]: - """Shortcut to config entry options.""" - return cast(Mapping[str, Any], self.coordinator.config_entry.options) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - # Get a shortcut to this Member's data. This needs to be updated each time since - # coordinator provides a new Life360Member object each time, and it's possible - # that there is no data for this Member on some updates. - if self.available: - self._data = self.coordinator.data.members.get(self._attr_unique_id) - else: - self._data = None - - if self._data: - # Check if we should effectively throw out new location data. - last_seen = self._data.last_seen - prev_seen = self._prev_data.last_seen - max_gps_acc = self._options.get(CONF_MAX_GPS_ACCURACY) - bad_last_seen = last_seen < prev_seen - bad_accuracy = ( - max_gps_acc is not None and self.location_accuracy > max_gps_acc - ) - if bad_last_seen or bad_accuracy: - if bad_last_seen: - LOGGER.warning( - ( - "%s: Ignoring location update because " - "last_seen (%s) < previous last_seen (%s)" - ), - self.entity_id, - last_seen, - prev_seen, - ) - if bad_accuracy: - LOGGER.warning( - ( - "%s: Ignoring location update because " - "expected GPS accuracy (%0.1f) is not met: %i" - ), - self.entity_id, - max_gps_acc, - self.location_accuracy, - ) - # Overwrite new location related data with previous values. - for attr in _LOC_ATTRS: - setattr(self._data, attr, getattr(self._prev_data, attr)) - - else: - # Process address field. - # Check if we got the name of a Place, which we won't use. - if (address := self._data.address) == self._data.place: - address = None - if last_seen != prev_seen: - # We have new location data, so we might have a new pair of address - # values. - if address not in self._addresses: - # We do. - # Replace the old values with the first value of the new pair. - self._addresses = [address] - elif self._data.address != self._prev_data.address: - # Location data didn't change in general, but the address field did. - # There are three possibilities: - # 1. The new value is one of the pair we've already seen before. - # 2. The new value is the second of the pair we haven't seen yet. - # 3. The new value is the first of a new pair of values. - if address not in self._addresses: - if len(self._addresses) < 2: - self._addresses.append(address) - else: - self._addresses = [address] - - self._prev_data = self._data - - super()._handle_coordinator_update() - - @property - def force_update(self) -> bool: - """Return True if state updates should be forced. - - Overridden because CoordinatorEntity sets `should_poll` to False, - which causes TrackerEntity to set `force_update` to True. - """ - return False - - @property - def entity_picture(self) -> str | None: - """Return the entity picture to use in the frontend, if any.""" - if self._data: - self._attr_entity_picture = self._data.entity_picture - return super().entity_picture - - @property - def battery_level(self) -> int | None: - """Return the battery level of the device. - - Percentage from 0-100. - """ - if not self._data: - return None - return self._data.battery_level - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - - @property - def location_accuracy(self) -> int: - """Return the location accuracy of the device. - - Value in meters. - """ - if not self._data: - return 0 - return self._data.gps_accuracy - - @property - def driving(self) -> bool: - """Return if driving.""" - if not self._data: - return False - if (driving_speed := self._options.get(CONF_DRIVING_SPEED)) is not None: - if self._data.speed >= driving_speed: - return True - return self._data.driving - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - if self._options.get(SHOW_DRIVING) and self.driving: - return "Driving" - return None - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - if not self._data: - return None - return self._data.latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - if not self._data: - return None - return self._data.longitude - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - if not self._data: - return { - ATTR_ADDRESS: None, - ATTR_AT_LOC_SINCE: None, - ATTR_BATTERY_CHARGING: None, - ATTR_DRIVING: None, - ATTR_LAST_SEEN: None, - ATTR_PLACE: None, - ATTR_SPEED: None, - ATTR_WIFI_ON: None, - } - - # Generate address attribute from pair of address values. - # There may be two, one or no values. If there are two, sort the strings since - # one value is typically a numbered street address and the other is a street, - # town or state name, and it's helpful to start with the more detailed address - # value. Also, sorting helps to generate the same result if we get a location - # update, and the same pair is sent afterwards, but where the value that comes - # first is swapped vs the order they came in before the update. - address1: str | None = None - address2: str | None = None - with suppress(IndexError): - address1 = self._addresses[0] - address2 = self._addresses[1] - if address1 and address2: - address: str | None = " / ".join(sorted([address1, address2])) - else: - address = address1 or address2 - - return { - ATTR_ADDRESS: address, - ATTR_AT_LOC_SINCE: self._data.at_loc_since, - ATTR_BATTERY_CHARGING: self._data.battery_charging, - ATTR_DRIVING: self.driving, - ATTR_LAST_SEEN: self._data.last_seen, - ATTR_PLACE: self._data.place, - ATTR_SPEED: self._data.speed, - ATTR_WIFI_ON: self._data.wifi_on, - } diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json index 481d006809d..da304cf4485 100644 --- a/homeassistant/components/life360/manifest.json +++ b/homeassistant/components/life360/manifest.json @@ -1,10 +1,9 @@ { "domain": "life360", "name": "Life360", - "codeowners": ["@pnbruckner"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/life360", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["life360"], - "requirements": ["life360==6.0.1"] + "requirements": [] } diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index 343d9e95bb8..885b3203f52 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -1,51 +1,8 @@ { - "config": { - "step": { - "user": { - "title": "Configure Life360 Account", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "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%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "button": { - "update_location": { - "name": "Update Location" - } - } - }, - "options": { - "step": { - "init": { - "title": "Account Options", - "data": { - "limit_gps_acc": "Limit GPS accuracy", - "max_gps_accuracy": "Max GPS accuracy (meters)", - "set_drive_speed": "Set driving speed threshold", - "driving_speed": "Driving speed", - "driving": "Show driving as state" - } - } + "issues": { + "integration_removed": { + "title": "The Life360 integration has been removed", + "description": "The Life360 integration has been removed from Home Assistant.\n\nLife360 has blocked all third-party integrations.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Life360 integration entries]({entries})." } } } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index ebd3696d61f..4abe18daa21 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -160,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: @@ -234,6 +234,7 @@ ATTR_EFFECT_LIST = "effect_list" # Apply an effect to the light, can be EFFECT_COLORLOOP. ATTR_EFFECT = "effect" EFFECT_COLORLOOP = "colorloop" +EFFECT_OFF = "off" EFFECT_RANDOM = "random" EFFECT_WHITE = "white" @@ -345,6 +346,9 @@ 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.""" + if not params: + return params + supported_features = light.supported_features_compat if LightEntityFeature.FLASH not in supported_features: @@ -604,7 +608,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) # 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 + # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an # integer. if params.get(ATTR_WHITE) is True: params[ATTR_WHITE] = light.brightness @@ -893,7 +897,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the color mode of the light with backwards compatibility.""" if (color_mode := self.color_mode) is None: # Backwards compatibility for color_mode added in 2021.4 - # Add warning in 2021.6, remove in 2021.10 + # Add warning in 2024.3, remove in 2025.3 supported = self._light_internal_supported_color_modes if ColorMode.HS in supported and self.hs_color is not None: @@ -947,8 +951,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @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 @cached_property @@ -993,19 +997,21 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list @@ -1018,30 +1024,27 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 ) @@ -1049,56 +1052,105 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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) return data + def __validate_color_mode( + self, + color_mode: ColorMode | str | None, + supported_color_modes: set[ColorMode] | set[str], + effect: str | None, + ) -> None: + """Validate the color mode.""" + if color_mode is None: + # The light is turned off + return + + if not effect or effect == EFFECT_OFF: + # No effect is active, the light must set color mode to one of the supported + # color modes + if color_mode in supported_color_modes: + return + # Increase severity to warning in 2024.3, reject in 2025.3 + _LOGGER.debug( + "%s: set to unsupported color_mode: %s, supported_color_modes: %s", + self.entity_id, + color_mode, + supported_color_modes, + ) + return + + # When an effect is active, the color mode should indicate what adjustments are + # supported by the effect. To make this possible, we allow the light to set its + # color mode to on_off, and to brightness if the light allows adjusting + # brightness, in addition to the otherwise supported color modes. + effect_color_modes = supported_color_modes | {ColorMode.ONOFF} + if brightness_supported(effect_color_modes): + effect_color_modes.add(ColorMode.BRIGHTNESS) + + if color_mode in effect_color_modes: + return + + # Increase severity to warning in 2024.3, reject in 2025.3 + _LOGGER.debug( + "%s: set to unsupported color_mode: %s, supported for effect: %s", + self.entity_id, + color_mode, + effect_color_modes, + ) + return + @final @property def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} supported_features = self.supported_features_compat - supported_color_modes = self._light_internal_supported_color_modes + 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 - color_mode = self._light_internal_color_mode if self.is_on else None + _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: - # 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, - ) + effect: str | None + if LightEntityFeature.EFFECT in supported_features: + data[ATTR_EFFECT] = effect = self.effect if _is_on else None + else: + effect = None + + self.__validate_color_mode(color_mode, legacy_supported_color_modes, effect) 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_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states - # Add warning in 2021.6, remove in 2021.10 - if self.is_on: + # Add warning in 2024.3, remove in 2025.3 + 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: @@ -1106,47 +1158,43 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_COLOR_TEMP] = None 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: + # Add warning in 2024.3, remove in 2025.3 + 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 LightEntityFeature.EFFECT in supported_features: - data[ATTR_EFFECT] = self.effect if self.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 + # Add warning in 2024.3, remove in 2025.3 supported_features = self.supported_features_compat supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json new file mode 100644 index 00000000000..5113834e575 --- /dev/null +++ b/homeassistant/components/light/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:lightbulb" + } + }, + "services": { + "toggle": "mdi:lightbulb", + "turn_off": "mdi:lightbulb-off", + "turn_on": "mdi:lightbulb-on" + } +} diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 60108aba024..5e89e4f8145 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -47,9 +47,14 @@ class LightwaveTrv(ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = DEFAULT_MIN_TEMP _attr_max_temp = DEFAULT_MAX_TEMP - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 0.5 _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, device_id, lwlink, serial): """Initialize LightwaveTrv entity.""" diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index c1dfeda172c..926e0a8a6d6 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -1,8 +1,9 @@ """Support for LimitlessLED bulbs.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar, cast from limitlessled import Color from limitlessled.bridge import Bridge @@ -38,6 +39,9 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin +_LimitlessLEDGroupT = TypeVar("_LimitlessLEDGroupT", bound="LimitlessLEDGroup") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) CONF_BRIDGES = "bridges" @@ -171,16 +175,25 @@ def setup_platform( add_entities(lights) -def state(new_state): +def state( + new_state: bool, +) -> Callable[ + [Callable[Concatenate[_LimitlessLEDGroupT, int, Pipeline, _P], Any]], + Callable[Concatenate[_LimitlessLEDGroupT, _P], None], +]: """State decorator. Specify True (turn on) or False (turn off). """ - def decorator(function): + def decorator( + function: Callable[Concatenate[_LimitlessLEDGroupT, int, Pipeline, _P], Any], + ) -> Callable[Concatenate[_LimitlessLEDGroupT, _P], None]: """Set up the decorator function.""" - def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: + def wrapper( + self: _LimitlessLEDGroupT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: """Wrap a group state change.""" pipeline = Pipeline() transition_time = DEFAULT_TRANSITION @@ -189,9 +202,9 @@ def state(new_state): self._attr_effect = None # pylint: disable=protected-access # Set transition time. if ATTR_TRANSITION in kwargs: - transition_time = int(kwargs[ATTR_TRANSITION]) + transition_time = int(cast(float, kwargs[ATTR_TRANSITION])) # Do group type-specific work. - function(self, transition_time, pipeline, **kwargs) + function(self, transition_time, pipeline, *args, **kwargs) # Update state. self._attr_is_on = new_state # pylint: disable=protected-access self.group.enqueue(pipeline) diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index 765e0d79537..08b2dc33bae 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_NAME, CONF_NAME, PERCENTAGE +from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -31,7 +31,6 @@ ATTR_ENERGY_NOW = "energy_now" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL_NAME = "model_name" ATTR_POWER_NOW = "power_now" -ATTR_SERIAL_NUMBER = "serial_number" ATTR_STATUS = "status" ATTR_VOLTAGE_MIN_DESIGN = "voltage_min_design" ATTR_VOLTAGE_NOW = "voltage_now" diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 8c6d5ef4487..da24aee9ab8 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -2,49 +2,16 @@ import logging import pylitejet -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import CONF_EXCLUDE_NAMES, CONF_INCLUDE_SWITCHES, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_EXCLUDE_NAMES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LiteJet component.""" - if DOMAIN in config: - # Configuration.yaml config exists, trigger the import flow. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LiteJet via a config entry.""" diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 1062e948090..a7b5a6f000e 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -9,10 +9,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, callback -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -54,21 +53,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Create a LiteJet config entry based upon user input.""" if self._async_current_entries(): - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LiteJet", - }, - ) return self.async_abort(reason="single_instance_allowed") errors = {} @@ -78,20 +62,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: system = await pylitejet.open(port) except SerialException: - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_serial_exception", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key="deprecated_yaml_serial_exception", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=litejet" - }, - ) errors[CONF_PORT] = "open_failed" else: await system.close() @@ -106,27 +76,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: - """Import litejet config from configuration.yaml.""" - new_data = {CONF_PORT: import_data[CONF_PORT]} - result = await self.async_step_user(new_data) - if result["type"] == FlowResultType.CREATE_ENTRY: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LiteJet", - }, - ) - return result - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json index 288e5f959a8..398f1a1e5aa 100644 --- a/homeassistant/components/litejet/strings.json +++ b/homeassistant/components/litejet/strings.json @@ -25,11 +25,5 @@ } } } - }, - "issues": { - "deprecated_yaml_serial_exception": { - "title": "The LiteJet YAML configuration import failed", - "description": "Configuring LiteJet using YAML is being removed but there was an error opening the serial port when importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or manually continue to [set up the integration]({url})." - } } } diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 85d75e13dd2..7acfad69735 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -25,30 +25,6 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, - "issues": { - "service_deprecation_turn_off": { - "title": "Litter-Robot vaccum support for {old_service} is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "description": "Litter-Robot vaccum support for the {old_service} service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call {new_service} and select submit below to mark this issue as resolved." - } - } - } - }, - "service_deprecation_turn_on": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "description": "[%key:component::litterrobot::issues::service_deprecation_turn_off::fix_flow::step::confirm::description%]" - } - } - } - } - }, "entity": { "binary_sensor": { "sleeping": { diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index a86f1e4be00..681af81481d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -20,11 +20,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -79,11 +75,7 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): """Litter-Robot "Vacuum" Cleaner.""" _attr_supported_features = ( - VacuumEntityFeature.START - | VacuumEntityFeature.STATE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.TURN_ON + VacuumEntityFeature.START | VacuumEntityFeature.STATE | VacuumEntityFeature.STOP ) @property @@ -98,42 +90,6 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): f"{self.robot.status.text}{' (Sleeping)' if self.robot.is_sleeping else ''}" ) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the cleaner on, starting a clean cycle.""" - await self.robot.set_power_status(True) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_on", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_on", - translation_placeholders={ - "old_service": "vacuum.turn_on", - "new_service": "vacuum.start", - }, - ) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the unit off, stopping any cleaning in progress as is.""" - await self.robot.set_power_status(False) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_off", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_off", - translation_placeholders={ - "old_service": "vacuum.turn_off", - "new_service": "vacuum.stop", - }, - ) - async def async_start(self) -> None: """Start a clean cycle.""" await self.robot.set_power_status(True) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 952363650d6..6990dabff1d 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -67,6 +67,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 99fb6dcebfa..e94206317d7 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -127,7 +127,7 @@ class LocalTodoListEntity(TodoListEntity): await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: - """Add an item to the To-do list.""" + """Delete an item from the To-do list.""" store = TodoStore(self._calendar) for uid in uids: store.delete(uid) diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json new file mode 100644 index 00000000000..1bf48f2ab40 --- /dev/null +++ b/homeassistant/components/lock/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:lock", + "state": { + "jammed": "mdi:lock-alert", + "locking": "mdi:lock-clock", + "unlocked": "mdi:lock-open", + "unlocking": "mdi:lock-clock" + } + } + }, + "services": { + "lock": "mdi:lock", + "open": "mdi:door-open", + "unlock": "mdi:lock-open" + } +} diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index c2ea9823535..839a742224f 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -39,7 +39,8 @@ def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[st return [ entity_id for entity_id in entity_ids - if not _is_entity_id_filtered(hass, ent_reg, entity_id) + if split_entity_id(entity_id)[0] not in ALWAYS_CONTINUOUS_DOMAINS + and not is_sensor_continuous(hass, ent_reg, entity_id) ] @@ -171,7 +172,6 @@ def async_subscribe_events( These are the events we need to listen for to do the live logbook stream. """ - ent_reg = er.async_get(hass) assert is_callback(target), "target must be a callback" event_forwarder = event_forwarder_filtered( target, entities_filter, entity_ids, device_ids @@ -193,7 +193,7 @@ def async_subscribe_events( new_state := event.data["new_state"] ) is None: return - if _is_state_filtered(ent_reg, new_state, old_state) or ( + if _is_state_filtered(new_state, old_state) or ( entities_filter and not entities_filter(new_state.entity_id) ): return @@ -217,24 +217,41 @@ def async_subscribe_events( ) -def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool: - """Determine if a sensor is continuous by checking its state class. +def is_sensor_continuous( + hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str +) -> bool: + """Determine if a sensor is continuous. - Sensors with a unit_of_measurement are also considered continuous, but are filtered - already by the SQL query generated by _get_events + Sensors with a unit_of_measurement or state_class are considered continuous. + + The unit_of_measurement check will already happen if this is + called for historical data because the SQL query generated by _get_events + will filter out any sensors with a unit_of_measurement. + + If the state still exists in the state machine, this function still + checks for ATTR_UNIT_OF_MEASUREMENT since the live mode is not filtered + by the SQL query. """ - if not (entry := ent_reg.async_get(entity_id)): - # Entity not registered, so can't have a state class - return False - return ( - entry.capabilities is not None - and entry.capabilities.get(ATTR_STATE_CLASS) is not None + # If it is in the state machine we can quick check if it + # has a unit_of_measurement or state_class, and filter if + # it does + if (state := hass.states.get(entity_id)) and (attributes := state.attributes): + return ATTR_UNIT_OF_MEASUREMENT in attributes or ATTR_STATE_CLASS in attributes + # If its not in the state machine, we need to check + # the entity registry to see if its a sensor + # filter with a state class. We do not check + # for unit_of_measurement since the SQL query + # will filter out any sensors with a unit_of_measurement + # and we should never get here in live mode because + # the state machine will always have the state. + return bool( + (entry := ent_reg.async_get(entity_id)) + and entry.capabilities + and entry.capabilities.get(ATTR_STATE_CLASS) ) -def _is_state_filtered( - ent_reg: er.EntityRegistry, new_state: State, old_state: State -) -> bool: +def _is_state_filtered(new_state: State, old_state: State) -> bool: """Check if the logbook should filter a state. Used when we are in live mode to ensure @@ -242,24 +259,8 @@ def _is_state_filtered( """ return bool( new_state.state == old_state.state - or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS or new_state.last_changed != new_state.last_updated + or new_state.domain in ALWAYS_CONTINUOUS_DOMAINS or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes - or is_sensor_continuous(ent_reg, new_state.entity_id) - ) - - -def _is_entity_id_filtered( - hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str -) -> bool: - """Check if the logbook should filter an entity. - - Used to setup listeners and which entities to select - from the database when a list of entities is requested. - """ - return bool( - split_entity_id(entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS - or (state := hass.states.get(entity_id)) - and (ATTR_UNIT_OF_MEASUREMENT in state.attributes) - or is_sensor_continuous(ent_reg, entity_id) + or ATTR_STATE_CLASS in new_state.attributes ) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 6939904f520..84ae84a3b70 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy.engine.row import Row @@ -16,10 +16,14 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback -import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + @dataclass(slots=True) class LogbookConfig: @@ -35,16 +39,6 @@ class LogbookConfig: class LazyEventPartialState: """A lazy version of core Event with limited State joined in.""" - __slots__ = [ - "row", - "_event_data", - "_event_data_cache", - "event_type", - "entity_id", - "state", - "data", - ] - def __init__( self, row: Row | EventAsRow, @@ -54,9 +48,6 @@ class LazyEventPartialState: self.row = row self._event_data: dict[str, Any] | None = None self._event_data_cache = event_data_cache - self.event_type: str | None = self.row.event_type - self.entity_id: str | None = self.row.entity_id - self.state = self.row.state # We need to explicitly check for the row is EventAsRow as the unhappy path # to fetch row.data for Row is very expensive if type(row) is EventAsRow: # noqa: E721 @@ -64,7 +55,10 @@ class LazyEventPartialState: # json decode process as we already have the data self.data = row.data return - source = cast(str, self.row.event_data) + if TYPE_CHECKING: + source = cast(str, row.event_data) + else: + source = row.event_data if not source: self.data = {} elif event_data := self._event_data_cache.get(source): @@ -74,17 +68,32 @@ class LazyEventPartialState: dict[str, Any], json_loads(source) ) - @property + @cached_property + def event_type(self) -> str | None: + """Return the event type.""" + return self.row.event_type + + @cached_property + def entity_id(self) -> str | None: + """Return the entity id.""" + return self.row.entity_id + + @cached_property + def state(self) -> str | None: + """Return the state.""" + return self.row.state + + @cached_property def context_id(self) -> str | None: """Return the context id.""" return bytes_to_ulid_or_none(self.row.context_id_bin) - @property + @cached_property def context_user_id(self) -> str | None: """Return the context user id.""" return bytes_to_uuid_hex_or_none(self.row.context_user_id_bin) - @property + @cached_property def context_parent_id(self) -> str | None: """Return the context parent id.""" return bytes_to_ulid_or_none(self.row.context_parent_id_bin) @@ -121,7 +130,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_id_bin=ulid_to_bytes(context.id), context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), - time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), + time_fired_ts=event.time_fired_timestamp, row_id=hash(event), ) # States are prefiltered so we never get states @@ -137,7 +146,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_id_bin=ulid_to_bytes(context.id), context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), - time_fired_ts=dt_util.utc_to_timestamp(new_state.last_updated), + time_fired_ts=new_state.last_updated_timestamp, row_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), ) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 671f8f8f1c2..02a6dae3ce6 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -175,6 +175,7 @@ class EventProcessor: """Humanify rows.""" return list( _humanify( + self.hass, rows, self.ent_reg, self.logbook_run, @@ -184,6 +185,7 @@ class EventProcessor: def _humanify( + hass: HomeAssistant, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result, ent_reg: er.EntityRegistry, logbook_run: LogbookRun, @@ -219,7 +221,7 @@ def _humanify( if ( is_continuous := continuous_sensors.get(entity_id) ) is None and split_entity_id(entity_id)[0] == SENSOR_DOMAIN: - is_continuous = is_sensor_continuous(ent_reg, entity_id) + is_continuous = is_sensor_continuous(hass, ent_reg, entity_id) continuous_sensors[entity_id] = is_continuous if is_continuous: continue @@ -425,7 +427,7 @@ class EventCache: def get(self, row: EventAsRow | Row) -> LazyEventPartialState: """Get the event from the row.""" - if isinstance(row, EventAsRow): + if type(row) is EventAsRow: # noqa: E721 - this is never subclassed return LazyEventPartialState(row, self._event_data_cache) if event := self.event_cache.get(row): return event diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index aad9c122d23..27ad49b0e3a 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/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 4afa40cb14f..82124247adf 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes import homeassistant.util.dt as dt_util from .const import DOMAIN @@ -70,7 +70,7 @@ def _async_send_empty_response( stream_end_time = end_time or dt_util.utcnow() empty_stream_message = _generate_stream_message([], start_time, stream_end_time) empty_response = messages.event_message(msg_id, empty_stream_message) - connection.send_message(JSON_DUMP(empty_response)) + connection.send_message(json_bytes(empty_response)) async def _async_send_historical_events( @@ -165,7 +165,7 @@ async def _async_get_ws_stream_events( formatter: Callable[[int, Any], dict[str, Any]], event_processor: EventProcessor, partial: bool, -) -> tuple[str, dt | None]: +) -> tuple[bytes, dt | None]: """Async wrapper around _ws_formatted_get_events.""" return await get_instance(hass).async_add_executor_job( _ws_stream_get_events, @@ -196,7 +196,7 @@ def _ws_stream_get_events( formatter: Callable[[int, Any], dict[str, Any]], event_processor: EventProcessor, partial: bool, -) -> tuple[str, dt | None]: +) -> tuple[bytes, dt | None]: """Fetch events and convert them to json in the executor.""" events = event_processor.get_events(start_day, end_day) last_time = None @@ -209,7 +209,7 @@ def _ws_stream_get_events( # data in case the UI needs to show that historical # data is still loading in the future message["partial"] = True - return JSON_DUMP(formatter(msg_id, message)), last_time + return json_bytes(formatter(msg_id, message)), last_time async def _async_events_consumer( @@ -238,7 +238,7 @@ async def _async_events_consumer( async_event_to_row(e) for e in events ): connection.send_message( - JSON_DUMP( + json_bytes( messages.event_message( msg_id, {"events": logbook_events}, @@ -435,9 +435,9 @@ def _ws_formatted_get_events( start_time: dt, end_time: dt, event_processor: EventProcessor, -) -> str: +) -> bytes: """Fetch events and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, event_processor.get_events(start_time, end_time) ) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 87ec2cc8cd5..bf37ab3625b 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import contextlib from dataclasses import asdict, dataclass from enum import StrEnum +from functools import lru_cache import logging from typing import Any, cast @@ -216,3 +217,11 @@ class LoggerSettings: ) return dict(combined_logs) + + +get_logger = lru_cache(maxsize=256)(logging.getLogger) +"""Get a logger. + +getLogger uses a threading.RLock, so we cache the result to avoid +locking the threads every time the integrations page is loaded. +""" diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 89026a07b8a..240db3144af 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API handlers for the logger integration.""" -import logging from typing import Any import voluptuous as vol @@ -16,6 +15,7 @@ from .helpers import ( LogPersistance, LogSettingsType, async_get_domain_config, + get_logger, ) @@ -38,7 +38,7 @@ def handle_integration_log_info( [ { "domain": integration, - "level": logging.getLogger( + "level": get_logger( f"homeassistant.components.{integration}" ).getEffectiveLevel(), } diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index f09bedab201..1bee2d14295 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -97,6 +97,8 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes: list[str] = LOOKIN_FAN_MODE_IDX_TO_HASS _attr_swing_modes: list[str] = LOOKIN_SWING_MODE_IDX_TO_HASS @@ -104,6 +106,7 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): _attr_min_temp = MIN_TEMP _attr_max_temp = MAX_TEMP _attr_target_temperature_step = PRECISION_WHOLE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index 9beeb0f20ee..f937c7edd10 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -1,23 +1,28 @@ """Support for Lupusec Home Security system.""" +from json import JSONDecodeError import logging import lupupy from lupupy.exceptions import LupusecException import voluptuous as vol -from homeassistant.components import persistent_notification +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.entity import Entity +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .const import INTEGRATION_TITLE, ISSUE_PLACEHOLDER + _LOGGER = logging.getLogger(__name__) DOMAIN = "lupusec" @@ -39,62 +44,83 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -LUPUSEC_PLATFORMS = [ +PLATFORMS: list[Platform] = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH, ] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Lupusec component.""" - conf = config[DOMAIN] - username = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] - ip_address = conf[CONF_IP_ADDRESS] - name = conf.get(CONF_NAME) +async def handle_async_init_result(hass: HomeAssistant, domain: str, conf: dict): + """Handle the result of the async_init to issue deprecated warnings.""" + flow = hass.config_entries.flow + result = await flow.async_init(domain, context={"source": SOURCE_IMPORT}, data=conf) - try: - hass.data[DOMAIN] = LupusecSystem(username, password, ip_address, name) - except LupusecException as ex: - _LOGGER.error(ex) - - persistent_notification.create( + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( hass, - f"Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, ) - return False - for platform in LUPUSEC_PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the lupusec integration.""" + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + hass.async_create_task(handle_async_init_result(hass, DOMAIN, conf)) return True -class LupusecSystem: - """Lupusec System class.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" - def __init__(self, username, password, ip_address, name): - """Initialize the system.""" - self.lupusec = lupupy.Lupusec(username, password, ip_address) - self.name = name + host = entry.data[CONF_HOST] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + try: + lupusec_system = await hass.async_add_executor_job( + lupupy.Lupusec, username, password, host + ) + except LupusecException: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) + return False + except JSONDecodeError: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) + return False -class LupusecDevice(Entity): - """Representation of a Lupusec device.""" + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system - def __init__(self, data, device): - """Initialize a sensor for Lupusec device.""" - self._data = data - self._device = device + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def update(self): - """Update automation state.""" - self._device.refresh() - - @property - def name(self): - """Return the name of the sensor.""" - return self._device.name + return True diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 2ae0b5944bd..cd4e433bd5d 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -3,10 +3,13 @@ from __future__ import annotations from datetime import timedelta +import lupupy + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -14,40 +17,51 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" - if discovery_info is None: - return + data = hass.data[DOMAIN][config_entry.entry_id] - data = hass.data[LUPUSEC_DOMAIN] + alarm = await hass.async_add_executor_job(data.get_alarm) - alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())] - - add_entities(alarm_devices) + async_add_entities([LupusecAlarm(data, alarm, config_entry.entry_id)]) class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): """An alarm_control_panel implementation for Lupusec.""" + _attr_name = None _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + def __init__( + self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str + ) -> None: + """Initialize the LupusecAlarm class.""" + super().__init__(device) + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=device.name, + manufacturer="Lupus Electronics", + model=f"Lupusec-XT{data.model}", + ) + @property def state(self) -> str | None: """Return the state of the device.""" diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index ee369baf8dd..5cf63579984 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -2,51 +2,62 @@ from __future__ import annotations from datetime import timedelta +from functools import partial +import logging import lupupy.constants as CONST -from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity +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.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) +_LOGGER = logging.getLogger(__name__) -def setup_platform( + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up a sensor for an Lupusec device.""" - if discovery_info is None: - return + """Set up a binary sensors for a Lupusec device.""" - data = hass.data[LUPUSEC_DOMAIN] + data = hass.data[DOMAIN][config_entry.entry_id] device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR - devices = [] - for device in data.lupusec.get_devices(generic_type=device_types): - devices.append(LupusecBinarySensor(data, device)) + sensors = [] + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: + sensors.append(LupusecBinarySensor(device, config_entry.entry_id)) - add_entities(devices) + async_add_entities(sensors) -class LupusecBinarySensor(LupusecDevice, BinarySensorEntity): +class LupusecBinarySensor(LupusecBaseSensor, BinarySensorEntity): """A binary sensor implementation for Lupusec device.""" + _attr_name = None + @property - def is_on(self): + def is_on(self) -> bool: """Return True if the binary sensor is on.""" return self._device.is_on @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" - if self._device.generic_type not in DEVICE_CLASSES: + if self._device.generic_type not in ( + item.value for item in BinarySensorDeviceClass + ): return None return self._device.generic_type diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py new file mode 100644 index 00000000000..aad57897c91 --- /dev/null +++ b/homeassistant/components/lupusec/config_flow.py @@ -0,0 +1,115 @@ +""""Config flow for Lupusec integration.""" + +from json import JSONDecodeError +import logging +from typing import Any + +import lupupy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Lupusec config flow.""" + + 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: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + try: + await test_host_connection(self.hass, host, username, password) + except CannotConnect: + errors["base"] = "cannot_connect" + except JSONDecodeError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + return self.async_create_entry( + title=host, + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_IP_ADDRESS], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + host = user_input[CONF_IP_ADDRESS] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + await test_host_connection(self.hass, host, username, password) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except JSONDecodeError: + 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=user_input.get(CONF_NAME, host), + data={ + CONF_HOST: host, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + +async def test_host_connection( + hass: HomeAssistant, host: str, username: str, password: str +): + """Test if the host is reachable and is actually a Lupusec device.""" + + try: + await hass.async_add_executor_job(lupupy.Lupusec, username, password, host) + except lupupy.LupusecException: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) + raise CannotConnect + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/lupusec/const.py b/homeassistant/components/lupusec/const.py new file mode 100644 index 00000000000..489d878306d --- /dev/null +++ b/homeassistant/components/lupusec/const.py @@ -0,0 +1,39 @@ +"""Constants for the Lupusec component.""" + +from lupupy.constants import ( + TYPE_CONTACT_XT, + TYPE_DOOR, + TYPE_INDOOR_SIREN_XT, + TYPE_KEYPAD_V2, + TYPE_OUTDOOR_SIREN_XT, + TYPE_POWER_SWITCH, + TYPE_POWER_SWITCH_1_XT, + TYPE_POWER_SWITCH_2_XT, + TYPE_SMOKE, + TYPE_SMOKE_XT, + TYPE_WATER, + TYPE_WATER_XT, + TYPE_WINDOW, +) + +DOMAIN = "lupusec" + +INTEGRATION_TITLE = "Lupus Electronics LUPUSEC" +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=lupusec"} + + +TYPE_TRANSLATION = { + TYPE_WINDOW: "Fensterkontakt", + TYPE_DOOR: "Türkontakt", + TYPE_SMOKE: "Rauchmelder", + TYPE_WATER: "Wassermelder", + TYPE_POWER_SWITCH: "Steckdose", + TYPE_CONTACT_XT: "Fenster- / Türkontakt V2", + TYPE_WATER_XT: "Wassermelder V2", + TYPE_SMOKE_XT: "Rauchmelder V2", + TYPE_POWER_SWITCH_1_XT: "Funksteckdose", + TYPE_POWER_SWITCH_2_XT: "Funksteckdose V2", + TYPE_KEYPAD_V2: "Keypad V2", + TYPE_INDOOR_SIREN_XT: "Innensirene", + TYPE_OUTDOOR_SIREN_XT: "Außensirene V2", +} diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py new file mode 100644 index 00000000000..6237e5dd16b --- /dev/null +++ b/homeassistant/components/lupusec/entity.py @@ -0,0 +1,43 @@ +"""Provides the Lupusec entity for Home Assistant.""" +import lupupy + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, TYPE_TRANSLATION + + +class LupusecDevice(Entity): + """Representation of a Lupusec device.""" + + _attr_has_entity_name = True + + def __init__(self, device: lupupy.devices.LupusecDevice) -> None: + """Initialize a sensor for Lupusec device.""" + self._device = device + self._attr_unique_id = device.device_id + + def update(self): + """Update automation state.""" + self._device.refresh() + + +class LupusecBaseSensor(LupusecDevice): + """Lupusec Sensor base entity.""" + + def __init__(self, device: lupupy.devices.LupusecDevice, entry_id: str) -> None: + """Initialize the LupusecBaseSensor.""" + super().__init__(device) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=device.name, + manufacturer="Lupus Electronics", + serial_number=device.device_id, + model=TYPE_TRANSLATION.get(device.type, device.type), + via_device=(DOMAIN, entry_id), + ) + + def get_type_name(self) -> str: + """Return the type of the sensor.""" + return TYPE_TRANSLATION.get(self._device.type, self._device.type) diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index e73feef55a1..630ca71410e 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -1,9 +1,10 @@ { "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", - "codeowners": ["@majuss"], + "codeowners": ["@majuss", "@suaveolent"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.3.1"] + "requirements": ["lupupy==0.3.2"] } diff --git a/homeassistant/components/lupusec/strings.json b/homeassistant/components/lupusec/strings.json new file mode 100644 index 00000000000..6fa59aaeb3d --- /dev/null +++ b/homeassistant/components/lupusec/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Lupus Electronics LUPUSEC connection", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "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%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", + "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", + "description": "Configuring Lupus Electronics LUPUSEC 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 Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 37a3b2ec969..e07c974f033 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -2,44 +2,47 @@ from __future__ import annotations from datetime import timedelta +from functools import partial from typing import Any import lupupy.constants as CONST from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" - if discovery_info is None: - return - data = hass.data[LUPUSEC_DOMAIN] + data = hass.data[DOMAIN][config_entry.entry_id] device_types = CONST.TYPE_SWITCH - devices = [] - for device in data.lupusec.get_devices(generic_type=device_types): - devices.append(LupusecSwitch(data, device)) + switches = [] + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: + switches.append(LupusecSwitch(device, config_entry.entry_id)) - add_entities(devices) + async_add_entities(switches) -class LupusecSwitch(LupusecDevice, SwitchEntity): +class LupusecSwitch(LupusecBaseSensor, SwitchEntity): """Representation of a Lupusec switch.""" + _attr_name = None + def turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" self._device.switch_on() @@ -49,6 +52,6 @@ class LupusecSwitch(LupusecDevice, SwitchEntity): self._device.switch_off() @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.is_on diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index c15f0ea075e..ad1cbfe5ca6 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -1,9 +1,12 @@ """Component for interacting with a Lutron RadioRA 2 system.""" +from dataclasses import dataclass import logging -from pylutron import Button, Lutron +from pylutron import Button, Keypad, Led, Lutron, LutronEvent, OccupancyGroup, Output import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ID, CONF_HOST, @@ -11,29 +14,27 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +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 import Entity +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -DOMAIN = "lutron" +from .const import DOMAIN PLATFORMS = [ - Platform.LIGHT, - Platform.COVER, - Platform.SWITCH, - Platform.SCENE, Platform.BINARY_SENSOR, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.SCENE, + Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) -LUTRON_BUTTONS = "lutron_buttons" -LUTRON_CONTROLLER = "lutron_controller" -LUTRON_DEVICES = "lutron_devices" - # Attribute on events that indicates what action was taken with the button. ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" @@ -53,100 +54,56 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: - """Set up the Lutron integration.""" - hass.data[LUTRON_BUTTONS] = [] - hass.data[LUTRON_CONTROLLER] = None - hass.data[LUTRON_DEVICES] = { - "light": [], - "cover": [], - "switch": [], - "scene": [], - "binary_sensor": [], - } +async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: + """Import a config entry from configuration.yaml.""" - config = base_config[DOMAIN] - hass.data[LUTRON_CONTROLLER] = Lutron( - config[CONF_HOST], config[CONF_USERNAME], config[CONF_PASSWORD] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=base_config[DOMAIN], + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "single_instance_allowed" + ): + 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": "Lutron", + }, + ) + return + 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={ + "domain": DOMAIN, + "integration_title": "Lutron", + }, ) - hass.data[LUTRON_CONTROLLER].load_xml_db() - hass.data[LUTRON_CONTROLLER].connect() - _LOGGER.info("Connected to main repeater at %s", config[CONF_HOST]) - # Sort our devices into types - for area in hass.data[LUTRON_CONTROLLER].areas: - for output in area.outputs: - if output.type == "SYSTEM_SHADE": - hass.data[LUTRON_DEVICES]["cover"].append((area.name, output)) - elif output.is_dimmable: - hass.data[LUTRON_DEVICES]["light"].append((area.name, output)) - else: - hass.data[LUTRON_DEVICES]["switch"].append((area.name, output)) - for keypad in area.keypads: - for button in keypad.buttons: - # If the button has a function assigned to it, add it as a scene - if button.name != "Unknown Button" and button.button_type in ( - "SingleAction", - "Toggle", - "SingleSceneRaiseLower", - "MasterRaiseLower", - ): - # Associate an LED with a button if there is one - led = next( - (led for led in keypad.leds if led.number == button.number), - None, - ) - hass.data[LUTRON_DEVICES]["scene"].append( - (area.name, keypad.name, button, led) - ) - - hass.data[LUTRON_BUTTONS].append( - LutronButton(hass, area.name, keypad, button) - ) - if area.occupancy_group is not None: - hass.data[LUTRON_DEVICES]["binary_sensor"].append( - (area.name, area.occupancy_group) - ) - - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, base_config) +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Set up the Lutron component.""" + if DOMAIN in base_config: + hass.async_create_task(_async_import(hass, base_config)) return True -class LutronDevice(Entity): - """Representation of a Lutron device entity.""" - - _attr_should_poll = False - - def __init__(self, area_name, lutron_device, controller): - """Initialize the device.""" - self._lutron_device = lutron_device - self._controller = controller - self._area_name = area_name - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self._lutron_device.subscribe(self._update_callback, None) - - def _update_callback(self, _device, _context, _event, _params): - """Run when invoked by pylutron when the device state changes.""" - self.schedule_update_ha_state() - - @property - def name(self) -> str: - """Return the name of the device.""" - return f"{self._area_name} {self._lutron_device.name}" - - @property - def unique_id(self): - """Return a unique ID.""" - # Temporary fix for https://github.com/thecynic/pylutron/issues/70 - if self._lutron_device.uuid is None: - return None - return f"{self._controller.guid}_{self._lutron_device.uuid}" - - class LutronButton: """Representation of a button on a Lutron keypad. @@ -155,7 +112,9 @@ class LutronButton: represented as an entity; it simply fires events. """ - def __init__(self, hass, area_name, keypad, button): + def __init__( + self, hass: HomeAssistant, area_name: str, keypad: Keypad, button: Button + ) -> None: """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" if button.name == "Unknown Button": @@ -175,7 +134,9 @@ class LutronButton: button.subscribe(self.button_callback, None) - def button_callback(self, button, context, event, params): + def button_callback( + self, _button: Button, _context: None, event: LutronEvent, _params: dict + ) -> None: """Fire an event about a button being pressed or released.""" # Events per button type: # RaiseLower -> pressed/released @@ -197,3 +158,95 @@ class LutronButton: ATTR_UUID: self._uuid, } self._hass.bus.fire(self._event, data) + + +@dataclass(slots=True, kw_only=True) +class LutronData: + """Storage class for platform global data.""" + + client: Lutron + binary_sensors: list[tuple[str, OccupancyGroup]] + buttons: list[LutronButton] + covers: list[tuple[str, Output]] + fans: list[tuple[str, Output]] + lights: list[tuple[str, Output]] + scenes: list[tuple[str, Keypad, Button, Led]] + switches: list[tuple[str, Output]] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the Lutron integration.""" + + host = config_entry.data[CONF_HOST] + uid = config_entry.data[CONF_USERNAME] + pwd = config_entry.data[CONF_PASSWORD] + + lutron_client = Lutron(host, uid, pwd) + await hass.async_add_executor_job(lutron_client.load_xml_db) + lutron_client.connect() + _LOGGER.info("Connected to main repeater at %s", host) + + entry_data = LutronData( + client=lutron_client, + binary_sensors=[], + buttons=[], + covers=[], + fans=[], + lights=[], + scenes=[], + switches=[], + ) + # Sort our devices into types + _LOGGER.debug("Start adding devices") + for area in lutron_client.areas: + _LOGGER.debug("Working on area %s", area.name) + for output in area.outputs: + _LOGGER.debug("Working on output %s", output.type) + if output.type == "SYSTEM_SHADE": + entry_data.covers.append((area.name, output)) + elif output.type == "CEILING_FAN_TYPE": + entry_data.fans.append((area.name, output)) + # Deprecated, should be removed in 2024.8 + entry_data.lights.append((area.name, output)) + elif output.is_dimmable: + entry_data.lights.append((area.name, output)) + else: + entry_data.switches.append((area.name, output)) + for keypad in area.keypads: + for button in keypad.buttons: + # If the button has a function assigned to it, add it as a scene + if button.name != "Unknown Button" and button.button_type in ( + "SingleAction", + "Toggle", + "SingleSceneRaiseLower", + "MasterRaiseLower", + ): + # Associate an LED with a button if there is one + led = next( + (led for led in keypad.leds if led.number == button.number), + None, + ) + entry_data.scenes.append((area.name, keypad, button, led)) + + entry_data.buttons.append(LutronButton(hass, area.name, keypad, button)) + if area.occupancy_group is not None: + entry_data.binary_sensors.append((area.name, area.occupancy_group)) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, lutron_client.guid)}, + manufacturer="Lutron", + name="Main repeater", + ) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Clean up resources and entities associated with the integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 9f9851fb484..8cae9c9714a 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -1,34 +1,44 @@ """Support for Lutron Powr Savr occupancy sensors.""" from __future__ import annotations +from collections.abc import Mapping +import logging +from typing import Any + from pylutron import OccupancyGroup 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.typing import ConfigType, DiscoveryInfoType -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import DOMAIN, LutronData +from .entity import LutronDevice + +_LOGGER = logging.getLogger(__name__) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron occupancy sensors.""" - if discovery_info is None: - return - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: - dev = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron binary_sensor platform. - add_entities(devs) + Adds occupancy groups from the Main Repeater associated with the + config_entry as binary_sensor entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronOccupancySensor(area_name, device, entry_data.client) + for area_name, device in entry_data.binary_sensors + ], + True, + ) class LutronOccupancySensor(LutronDevice, BinarySensorEntity): @@ -39,23 +49,14 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): reported as a single occupancy group. """ + _lutron_device: OccupancyGroup _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @property - def is_on(self): - """Return true if the binary sensor is on.""" - # Error cases will end up treated as unoccupied. - return self._lutron_device.state == OccupancyGroup.State.OCCUPIED - - @property - def name(self): - """Return the name of the device.""" - # The default LutronDevice naming would create 'Kitchen Occ Kitchen', - # but since there can only be one OccupancyGroup per area we go - # with something shorter. - return f"{self._area_name} Occupancy" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} + + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.state == OccupancyGroup.State.OCCUPIED diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py new file mode 100644 index 00000000000..04628849230 --- /dev/null +++ b/homeassistant/components/lutron/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow to configure the Lutron integration.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.error import HTTPError + +from pylutron import Lutron +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +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__) + + +class LutronConfigFlow(ConfigFlow, domain=DOMAIN): + """User prompt for Main Repeater configuration information.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """First step in the config flow.""" + + # Check if a configuration entry already exists + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + ip_address = user_input[CONF_HOST] + + main_repeater = Lutron( + ip_address, + user_input.get(CONF_USERNAME), + user_input.get(CONF_PASSWORD), + ) + + try: + await self.hass.async_add_executor_job(main_repeater.load_xml_db) + except HTTPError: + _LOGGER.exception("Http error") + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + guid = main_repeater.guid + + if len(guid) <= 10: + errors["base"] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Lutron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="lutron"): str, + vol.Required(CONF_PASSWORD, default="integration"): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Attempt to import the existing configuration.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + main_repeater = Lutron( + import_config[CONF_HOST], + import_config[CONF_USERNAME], + import_config[CONF_PASSWORD], + ) + + def _load_db() -> None: + main_repeater.load_xml_db() + + try: + await self.hass.async_add_executor_job(_load_db) + except HTTPError: + _LOGGER.exception("Http error") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return self.async_abort(reason="unknown") + + guid = main_repeater.guid + + if len(guid) <= 10: + return self.async_abort(reason="cannot_connect") + _LOGGER.debug("Main Repeater GUID: %s", main_repeater.guid) + + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Lutron", data=import_config) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py new file mode 100644 index 00000000000..3862f7eb1d8 --- /dev/null +++ b/homeassistant/components/lutron/const.py @@ -0,0 +1,3 @@ +"""Lutron constants.""" + +DOMAIN = "lutron" diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 57fd8ac9d5b..cdcdf93ccbd 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -1,36 +1,45 @@ """Support for Lutron shades.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +from pylutron import Output + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, CoverEntityFeature, ) +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 . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import DOMAIN, LutronData +from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron shades.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: - dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron cover platform. - add_entities(devs, True) + Adds shades from the Main Repeater associated with the config_entry as + cover entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronCover(area_name, device, entry_data.client) + for area_name, device in entry_data.covers + ], + True, + ) class LutronCover(LutronDevice, CoverEntity): @@ -41,16 +50,8 @@ class LutronCover(LutronDevice, CoverEntity): | CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION ) - - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._lutron_device.last_level() < 1 - - @property - def current_cover_position(self) -> int: - """Return the current position of cover.""" - return self._lutron_device.last_level() + _lutron_device: Output + _attr_name = None def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" @@ -66,13 +67,18 @@ class LutronCover(LutronDevice, CoverEntity): position = kwargs[ATTR_POSITION] self._lutron_device.level = position - def update(self) -> None: - """Call when forcing a refresh of the device.""" - # Reading the property (rather than last_level()) fetches value - level = self._lutron_device.level + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_closed = level < 1 + self._attr_current_cover_position = level _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py new file mode 100644 index 00000000000..461e5acb56d --- /dev/null +++ b/homeassistant/components/lutron/entity.py @@ -0,0 +1,94 @@ +"""Base class for Lutron devices.""" + +from pylutron import Keypad, Lutron, LutronEntity, LutronEvent + +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_VIA_DEVICE +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class LutronBaseEntity(Entity): + """Base class for Lutron entities.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, area_name: str, lutron_device: LutronEntity, controller: Lutron + ) -> None: + """Initialize the device.""" + self._lutron_device = lutron_device + self._controller = controller + self._area_name = area_name + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._lutron_device.subscribe(self._update_callback, None) + + def _request_state(self) -> None: + """Request the state.""" + + def _update_attrs(self) -> None: + """Update the entity's attributes.""" + + def _update_callback( + self, _device: LutronEntity, _context: None, _event: LutronEvent, _params: dict + ) -> None: + """Run when invoked by pylutron when the device state changes.""" + self._update_attrs() + self.schedule_update_ha_state() + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + # Temporary fix for https://github.com/thecynic/pylutron/issues/70 + if self._lutron_device.uuid is None: + return None + return f"{self._controller.guid}_{self._lutron_device.uuid}" + + def update(self) -> None: + """Update the entity's state.""" + self._request_state() + self._update_attrs() + + +class LutronDevice(LutronBaseEntity): + """Representation of a Lutron device entity.""" + + def __init__( + self, area_name: str, lutron_device: LutronEntity, controller: Lutron + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lutron_device.uuid)}, + manufacturer="Lutron", + name=lutron_device.name, + suggested_area=area_name, + via_device=(DOMAIN, controller.guid), + ) + + +class LutronKeypad(LutronBaseEntity): + """Representation of a Lutron Keypad.""" + + def __init__( + self, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + keypad: Keypad, + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, keypad.id)}, + manufacturer="Lutron", + name=keypad.name, + ) + if keypad.type == "MAIN_REPEATER": + self._attr_device_info[ATTR_IDENTIFIERS].add((DOMAIN, controller.guid)) + else: + self._attr_device_info[ATTR_VIA_DEVICE] = (DOMAIN, controller.guid) diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py new file mode 100644 index 00000000000..4aac95759aa --- /dev/null +++ b/homeassistant/components/lutron/fan.py @@ -0,0 +1,89 @@ +"""Lutron fan platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from pylutron import Output + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, LutronData +from .entity import LutronDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Lutron fan platform. + + Adds fan controls from the Main Repeater associated with the config_entry as + fan entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronFan(area_name, device, entry_data.client) + for area_name, device in entry_data.fans + ], + True, + ) + + +class LutronFan(LutronDevice, FanEntity): + """Representation of a Lutron fan.""" + + _attr_name = None + _attr_should_poll = False + _attr_speed_count = 3 + _attr_supported_features = FanEntityFeature.SET_SPEED + _lutron_device: Output + _prev_percentage: int | None = None + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage > 0: + self._prev_percentage = percentage + self._lutron_device.level = percentage + self.schedule_update_ha_state() + + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + new_percentage: int | None = None + + if percentage is not None: + new_percentage = percentage + elif not self._prev_percentage: + # Default to medium speed + new_percentage = 67 + else: + new_percentage = self._prev_percentage + self.set_percentage(new_percentage) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self.set_percentage(0) + + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_on = level > 0 + self._attr_percentage = level + if self._prev_percentage is None or level != 0: + self._prev_percentage = level diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 6bd556d36d1..0bd00177cc1 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -1,29 +1,88 @@ """Support for Lutron lights.""" from __future__ import annotations +from collections.abc import Mapping +import logging from typing import Any +from pylutron import Output + +from homeassistant.components.automation import automations_with_entity from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.script import scripts_with_entity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + create_issue, +) -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import DOMAIN, LutronData +from .entity import LutronDevice + +_LOGGER = logging.getLogger(__name__) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron lights.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["light"]: - dev = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron light platform. - add_entities(devs, True) + Adds dimmers from the Main Repeater associated with the config_entry as + light entities. + """ + ent_reg = er.async_get(hass) + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + lights = [] + + for area_name, device in entry_data.lights: + if device.type == "CEILING_FAN_TYPE": + # If this is a fan, check to see if this entity already exists. + # If not, do not create a new one. + entity_id = ent_reg.async_get_entity_id( + Platform.LIGHT, + DOMAIN, + f"{entry_data.client.guid}_{device.uuid}", + ) + if entity_id: + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + if entity_entry.disabled: + # If the entity exists and is disabled then we want to remove + # the entity so that the user is using the new fan entity instead. + ent_reg.async_remove(entity_id) + else: + lights.append(LutronLight(area_name, device, entry_data.client)) + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + hass, + DOMAIN, + f"deprecated_light_fan_{entity_id}_{item}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_entity", + translation_placeholders={ + "entity": entity_id, + "info": item, + }, + ) + else: + lights.append(LutronLight(area_name, device, entry_data.client)) + + async_add_entities( + lights, + True, + ) def to_lutron_level(level): @@ -41,22 +100,28 @@ class LutronLight(LutronDevice, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _lutron_device: Output + _prev_brightness: int | None = None + _attr_name = None - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the light.""" - self._prev_brightness = None super().__init__(area_name, lutron_device, controller) - - @property - def brightness(self): - """Return the brightness of the light.""" - new_brightness = to_hass_level(self._lutron_device.last_level()) - if new_brightness != 0: - self._prev_brightness = new_brightness - return new_brightness + self._is_fan = lutron_device.type == "CEILING_FAN_TYPE" def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" + if self._is_fan: + create_issue( + self.hass, + DOMAIN, + "deprecated_light_fan_on", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_on", + ) if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: brightness = kwargs[ATTR_BRIGHTNESS] elif self._prev_brightness == 0: @@ -68,19 +133,33 @@ class LutronLight(LutronDevice, LightEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + if self._is_fan: + create_issue( + self.hass, + DOMAIN, + "deprecated_light_fan_off", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_off", + ) self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} - @property - def is_on(self): - """Return true if device is on.""" - return self._lutron_device.last_level() > 0 + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement - def update(self) -> None: - """Call when forcing a refresh of the device.""" - if self._prev_brightness is None: - self._prev_brightness = to_hass_level(self._lutron_device.level) + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_on = level > 0 + hass_level = to_hass_level(level) + self._attr_brightness = hass_level + if self._prev_brightness is None or hass_level != 0: + self._prev_brightness = hass_level diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 029e18d574a..6444aa306a2 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -1,7 +1,8 @@ { "domain": "lutron", "name": "Lutron", - "codeowners": ["@cdheiser"], + "codeowners": ["@cdheiser", "@wilburCForce"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index f2d008a1187..9485eddf78b 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -3,46 +3,51 @@ from __future__ import annotations from typing import Any +from pylutron import Button, Keypad, Lutron + from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import DOMAIN, LutronData +from .entity import LutronKeypad -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron scenes.""" - devs = [] - for scene_data in hass.data[LUTRON_DEVICES]["scene"]: - (area_name, keypad_name, device, led) = scene_data - dev = LutronScene( - area_name, keypad_name, device, led, hass.data[LUTRON_CONTROLLER] - ) - devs.append(dev) + """Set up the Lutron scene platform. - add_entities(devs, True) + Adds scenes from the Main Repeater associated with the config_entry as + scene entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LutronScene(area_name, keypad, device, entry_data.client) + for area_name, keypad, device, led in entry_data.scenes + ) -class LutronScene(LutronDevice, Scene): +class LutronScene(LutronKeypad, Scene): """Representation of a Lutron Scene.""" - def __init__(self, area_name, keypad_name, lutron_device, lutron_led, controller): + _lutron_device: Button + + def __init__( + self, + area_name: str, + keypad: Keypad, + lutron_device: Button, + controller: Lutron, + ) -> None: """Initialize the scene/button.""" - super().__init__(area_name, lutron_device, controller) - self._keypad_name = keypad_name - self._led = lutron_led + super().__init__(area_name, lutron_device, controller, keypad) + self._attr_name = lutron_device.name def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self._lutron_device.press() - - @property - def name(self) -> str: - """Return the name of the device.""" - return f"{self._area_name} {self._keypad_name}: {self._lutron_device.name}" diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json new file mode 100644 index 00000000000..efa0a35d81a --- /dev/null +++ b/homeassistant/components/lutron/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of the Lutron main repeater." + }, + "description": "Please enter the main repeater login information", + "title": "Main repeater setup" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Lutron YAML configuration import cannot connect to server", + "description": "Configuring Lutron using YAML is being removed but there was an connection error importing your YAML configuration.\n\nThings you can try:\nMake sure your home assistant can reach the main repeater.\nRestart the main repeater by unplugging it for 60 seconds.\nTry logging into the main repeater at the IP address you specified in a web browser and the same login information.\n\nThen restart Home Assistant to try importing this integration again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Lutron YAML configuration import request failed due to an unknown error", + "description": "Configuring Lutron using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nThe specific error can be found in the logs. The most likely cause is a networking error or the Main Repeater is down or has an invalid configuration.\n\nVerify that your Lutron system is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + }, + "deprecated_light_fan_entity": { + "title": "Detected Lutron fan entity created as a light", + "description": "Fan entities have been added to the Lutron integration.\nWe detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new fan entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant." + }, + "deprecated_light_fan_on": { + "title": "The Lutron integration deprecated fan turned on", + "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned on a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate." + }, + "deprecated_light_fan_off": { + "title": "The Lutron integration deprecated fan turned off", + "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned off a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate." + } + } +} diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 7d33a822087..0286fdef238 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -1,49 +1,48 @@ """Support for Lutron switches.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any +from pylutron import Button, Keypad, Led, Lutron, Output + from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import DOMAIN, LutronData +from .entity import LutronDevice, LutronKeypad -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron switches.""" - devs = [] + """Set up the Lutron switch platform. + + Adds switches from the Main Repeater associated with the config_entry as + switch entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entities: list[SwitchEntity] = [] # Add Lutron Switches - for area_name, device in hass.data[LUTRON_DEVICES]["switch"]: - dev = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + for area_name, device in entry_data.switches: + entities.append(LutronSwitch(area_name, device, entry_data.client)) # Add the indicator LEDs for scenes (keypad buttons) - for scene_data in hass.data[LUTRON_DEVICES]["scene"]: - (area_name, keypad_name, scene, led) = scene_data + for area_name, keypad, scene, led in entry_data.scenes: if led is not None: - led = LutronLed( - area_name, keypad_name, scene, led, hass.data[LUTRON_CONTROLLER] - ) - devs.append(led) - - add_entities(devs, True) + entities.append(LutronLed(area_name, keypad, scene, led, entry_data.client)) + async_add_entities(entities, True) class LutronSwitch(LutronDevice, SwitchEntity): """Representation of a Lutron Switch.""" - def __init__(self, area_name, lutron_device, controller): - """Initialize the switch.""" - self._prev_state = None - super().__init__(area_name, lutron_device, controller) + _lutron_device: Output def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -54,29 +53,36 @@ class LutronSwitch(LutronDevice, SwitchEntity): self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} - @property - def is_on(self): - """Return true if device is on.""" - return self._lutron_device.last_level() > 0 + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement - def update(self) -> None: - """Call when forcing a refresh of the device.""" - if self._prev_state is None: - self._prev_state = self._lutron_device.level > 0 + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.last_level() > 0 -class LutronLed(LutronDevice, SwitchEntity): +class LutronLed(LutronKeypad, SwitchEntity): """Representation of a Lutron Keypad LED.""" - def __init__(self, area_name, keypad_name, scene_device, led_device, controller): + _lutron_device: Led + + def __init__( + self, + area_name: str, + keypad: Keypad, + scene_device: Button, + led_device: Led, + controller: Lutron, + ) -> None: """Initialize the switch.""" - self._keypad_name = keypad_name - self._scene_name = scene_device.name - super().__init__(area_name, led_device, controller) + super().__init__(area_name, led_device, controller, keypad) + self._keypad_name = keypad.name + self._attr_name = scene_device.name def turn_on(self, **kwargs: Any) -> None: """Turn the LED on.""" @@ -87,25 +93,18 @@ class LutronLed(LutronDevice, SwitchEntity): self._lutron_device.state = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return { "keypad": self._keypad_name, - "scene": self._scene_name, + "scene": self._attr_name, "led": self._lutron_device.name, } - @property - def is_on(self): - """Return true if device is on.""" - return self._lutron_device.last_state - - @property - def name(self) -> str: - """Return the name of the LED.""" - return f"{self._area_name} {self._keypad_name}: {self._scene_name} LED" - - def update(self) -> None: - """Call when forcing a refresh of the device.""" - # The following property getter actually triggers an update in Lutron + def _request_state(self) -> None: + """Request the state from the device.""" self._lutron_device.state # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.last_state diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 0788af76aca..33cf6f21d6f 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -91,12 +91,12 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.SCENE, Platform.SWITCH, - Platform.BUTTON, ] diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index ff2831950c6..e549e37d59d 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.18.3"], + "requirements": ["pylutron-caseta==0.19.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index e2504232c68..ecf9b50474d 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -173,6 +173,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -182,6 +183,12 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device: LyricDevice, ) -> None: """Initialize Honeywell Lyric climate entity.""" + # Define thermostat type (TCC - e.g., Lyric round; LCC - e.g., T5,6) + if device.changeableValues.thermostatSetpointStatus: + self._attr_thermostat_type = LyricThermostatType.LCC + else: + self._attr_thermostat_type = LyricThermostatType.TCC + # Use the native temperature unit from the device settings if device.units == "Fahrenheit": self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT @@ -207,12 +214,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) # Setup supported features - if device.changeableValues.thermostatSetpointStatus: + if self._attr_thermostat_type is LyricThermostatType.LCC: 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( @@ -227,6 +232,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self._attr_supported_features | ClimateEntityFeature.FAN_MODE ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + super().__init__( coordinator, location, @@ -328,20 +338,19 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if device.changeableValues.autoChangeoverActive: + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL: if target_temp_low is None or target_temp_high is None: raise HomeAssistantError( "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] - ) + # If TCC device pass the heatCoolMode value, otherwise + # if LCC device can skip the mode altogether + if self._attr_thermostat_type is LyricThermostatType.TCC: + mode = HVAC_MODES[device.changeableValues.heatCoolMode] + else: + mode = None _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: @@ -385,12 +394,12 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self.coordinator.async_refresh() async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: + """Set hvac mode for TCC devices (e.g., Lyric round).""" 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 + # otherwise it turns to Auto briefly and then reverts to Off. + # 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", @@ -432,11 +441,23 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ) async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: + """Set hvac mode for LCC devices (e.g., T5,6).""" _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + # Set autoChangeoverActive to True if the mode being passed is Auto + # otherwise leave unchanged. + if ( + LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL + and not self.device.changeableValues.autoChangeoverActive + ): + auto_changeover = True + else: + auto_changeover = None + await self._update_thermostat( self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode], + autoChangeoverActive=auto_changeover, ) async def async_set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index a68741d4c33..0838bcc3764 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.22.1", "Pillow==10.1.0"] + "requirements": ["matrix-nio==0.24.0", "Pillow==10.2.0"] } diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index b58c4562994..3a82e466888 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -7,14 +7,13 @@ from functools import cache from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion -from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists -import voluptuous as vol +from matter_server.common.errors import MatterError, NodeNotExists from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import ( @@ -22,7 +21,6 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) -from homeassistant.helpers.service import async_register_admin_service from .adapter import MatterAdapter from .addon import get_addon_manager @@ -117,7 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DOMAIN not in hass.data: hass.data[DOMAIN] = {} - _async_init_services(hass) # create an intermediate layer (adapter) which keeps track of the nodes # and discovery of platform entities from the node attributes @@ -237,35 +234,6 @@ async def async_remove_config_entry_device( return True -@callback -def _async_init_services(hass: HomeAssistant) -> None: - """Init services.""" - - async def open_commissioning_window(call: ServiceCall) -> None: - """Open commissioning window on specific node.""" - node = node_from_ha_device_id(hass, call.data["device_id"]) - - if node is None: - raise HomeAssistantError("This is not a Matter device") - - matter_client = get_matter(hass).matter_client - - # We are sending device ID . - - try: - await matter_client.open_commissioning_window(node.node_id) - except NodeCommissionFailed as err: - raise HomeAssistantError(str(err)) from err - - async_register_admin_service( - hass, - DOMAIN, - "open_commissioning_window", - open_commissioning_window, - vol.Schema({"device_id": str}), - ) - - async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Matter Server add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 2df21d8f7a2..21445e469aa 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -5,7 +5,9 @@ from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate, ParamSpec +from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError +from matter_server.common.helpers.util import dataclass_to_dict import voluptuous as vol from homeassistant.components import websocket_api @@ -13,12 +15,16 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter -from .helpers import get_matter +from .helpers import MissingNode, get_matter, node_from_ha_device_id _P = ParamSpec("_P") ID = "id" TYPE = "type" +DEVICE_ID = "device_id" + + +ERROR_NODE_NOT_FOUND = "node_not_found" @callback @@ -28,6 +34,40 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_commission_on_network) websocket_api.async_register_command(hass, websocket_set_thread_dataset) websocket_api.async_register_command(hass, websocket_set_wifi_credentials) + websocket_api.async_register_command(hass, websocket_node_diagnostics) + websocket_api.async_register_command(hass, websocket_ping_node) + websocket_api.async_register_command(hass, websocket_open_commissioning_window) + websocket_api.async_register_command(hass, websocket_remove_matter_fabric) + websocket_api.async_register_command(hass, websocket_interview_node) + + +def async_get_node( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter, MatterNode], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], +]: + """Decorate async function to get node.""" + + @wraps(func) + async def async_get_node_func( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + ) -> None: + """Provide user specific data and store to function.""" + node = node_from_ha_device_id(hass, msg[DEVICE_ID]) + if not node: + raise MissingNode( + f"Could not resolve Matter node from device id {msg[DEVICE_ID]}" + ) + await func(hass, connection, msg, matter, node) + + return async_get_node_func def async_get_matter_adapter( @@ -76,6 +116,8 @@ def async_handle_failed_command( await func(hass, connection, msg, *args, **kwargs) except MatterError as err: connection.send_error(msg[ID], str(err.error_code), err.args[0]) + except MissingNode as err: + connection.send_error(msg[ID], ERROR_NODE_NOT_FOUND, err.args[0]) return async_handle_failed_command_func @@ -173,3 +215,119 @@ async def websocket_set_wifi_credentials( ssid=msg["network_name"], credentials=msg["password"] ) connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/node_diagnostics", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_node_diagnostics( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Gather diagnostics for the given node.""" + result = await matter.matter_client.node_diagnostics(node_id=node.node_id) + connection.send_result(msg[ID], dataclass_to_dict(result)) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/ping_node", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_ping_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Ping node on the currently known IP-adress(es).""" + result = await matter.matter_client.ping_node(node_id=node.node_id) + connection.send_result(msg[ID], result) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/open_commissioning_window", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_open_commissioning_window( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Open a commissioning window to commission a device present on this controller to another.""" + result = await matter.matter_client.open_commissioning_window(node_id=node.node_id) + connection.send_result(msg[ID], dataclass_to_dict(result)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/remove_matter_fabric", + vol.Required(DEVICE_ID): str, + vol.Required("fabric_index"): int, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_remove_matter_fabric( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Remove Matter fabric from a device.""" + await matter.matter_client.remove_matter_fabric( + node_id=node.node_id, fabric_index=msg["fabric_index"] + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/interview_node", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_interview_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Interview a node.""" + await matter.matter_client.interview_node(node_id=node.node_id) + connection.send_result(msg[ID]) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a22f9174d2a..8769fc430d8 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -73,11 +73,8 @@ class MatterClimate(MatterEntity, ClimateEntity): """Representation of a Matter climate entity.""" _attr_temperature_unit: str = UnitOfTemperature.CELSIUS - _attr_supported_features: ClimateEntityFeature = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ) _attr_hvac_mode: HVACMode = HVACMode.OFF + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -99,6 +96,13 @@ class MatterClimate(MatterEntity, ClimateEntity): self._attr_hvac_modes.append(HVACMode.COOL) if feature_map & ThermostatFeature.kAutoMode: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + ) + if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 6e25370d86a..1636790c4cb 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -76,19 +76,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Install Matter Server add-on.""" if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) + + if not self.install_task.done(): return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" + step_id="install_addon", + progress_action="install_addon", + progress_task=self.install_task, ) try: await self.install_task except AddonError as err: - self.install_task = None LOGGER.error(err) return self.async_show_progress_done(next_step_id="install_failed") + finally: + self.install_task = None self.integration_created_addon = True - self.install_task = None return self.async_show_progress_done(next_step_id="start_addon") @@ -101,13 +105,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_install_addon(self) -> None: """Install the Matter Server add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_install_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + await addon_manager.async_schedule_install_addon() async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" @@ -126,18 +124,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Start Matter Server add-on.""" if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) + if not self.start_task.done(): return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" + step_id="start_addon", + progress_action="start_addon", + progress_task=self.start_task, ) try: await self.start_task except (FailedConnect, AddonError, AbortFlow) as err: - self.start_task = None LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -150,33 +151,27 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Start the Matter Server add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_start_addon() - # Sleep some seconds to let the add-on start properly before connecting. - for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): - await asyncio.sleep(ADDON_SETUP_TIMEOUT) - try: - if not (ws_address := self.ws_address): - discovery_info = await self._async_get_addon_discovery_info() - ws_address = self.ws_address = build_ws_address( - discovery_info["host"], discovery_info["port"] - ) - await validate_input(self.hass, {CONF_URL: ws_address}) - except (AbortFlow, CannotConnect) as err: - LOGGER.debug( - "Add-on not ready yet, waiting %s seconds: %s", - ADDON_SETUP_TIMEOUT, - err, + await addon_manager.async_schedule_start_addon() + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not (ws_address := self.ws_address): + discovery_info = await self._async_get_addon_discovery_info() + ws_address = self.ws_address = build_ws_address( + discovery_info["host"], discovery_info["port"] ) - else: - break + await validate_input(self.hass, {CONF_URL: ws_address}) + except (AbortFlow, CannotConnect) as err: + LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) else: - raise FailedConnect("Failed to start Matter Server add-on: timeout") - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + break + else: + raise FailedConnect("Failed to start Matter Server add-on: timeout") async def _async_get_addon_info(self) -> AddonInfo: """Return Matter Server add-on info.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index e308699acad..61535d990db 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -82,6 +82,9 @@ class MatterEntity(Entity): self._attr_should_poll = entity_info.should_poll self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None + # make sure to update the attributes once + self._update_from_device() + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -115,9 +118,6 @@ class MatterEntity(Entity): ) ) - # make sure to update the attributes once - self._update_from_device() - async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" if self._extra_poll_timer_unsub: diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 446d5dc3591..8f7f3d81883 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID @@ -17,6 +18,10 @@ if TYPE_CHECKING: from .adapter import MatterAdapter +class MissingNode(HomeAssistantError): + """Exception raised when we can't find a node.""" + + @dataclass class MatterEntryData: """Hold Matter data for the config entry.""" @@ -72,7 +77,7 @@ def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode dev_reg = dr.async_get(hass) device = dev_reg.async_get(ha_device_id) if device is None: - raise ValueError("Invalid device ID") + raise MissingNode(f"Invalid device ID: {ha_device_id}") return get_node_from_device_entry(hass, device) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 43c47046162..7e6f42f44b4 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -14,6 +14,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityDescription, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -53,30 +54,9 @@ class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" entity_description: LightEntityDescription - - @property - def supports_color(self) -> bool: - """Return if the device supports color control.""" - if not self._attr_supported_color_modes: - return False - return ( - ColorMode.HS in self._attr_supported_color_modes - or ColorMode.XY in self._attr_supported_color_modes - ) - - @property - def supports_color_temperature(self) -> bool: - """Return if the device supports color temperature control.""" - if not self._attr_supported_color_modes: - return False - return ColorMode.COLOR_TEMP in self._attr_supported_color_modes - - @property - def supports_brightness(self) -> bool: - """Return if the device supports bridghtness control.""" - if not self._attr_supported_color_modes: - return False - return ColorMode.BRIGHTNESS in self._attr_supported_color_modes + _supports_brightness = False + _supports_color = False + _supports_color_temperature = False async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: """Set xy color.""" @@ -283,7 +263,7 @@ class MatterLight(MatterEntity, LightEntity): ): await self._set_color_temp(color_temp) - if brightness is not None and self.supports_brightness: + if brightness is not None and self._supports_brightness: await self._set_brightness(brightness) return @@ -302,12 +282,13 @@ class MatterLight(MatterEntity, LightEntity): """Update from device.""" if self._attr_supported_color_modes is None: # work out what (color)features are supported - supported_color_modes: set[ColorMode] = set() + supported_color_modes = {ColorMode.ONOFF} # brightness support if self._entity_info.endpoint.has_attribute( None, clusters.LevelControl.Attributes.CurrentLevel ): supported_color_modes.add(ColorMode.BRIGHTNESS) + self._supports_brightness = True # colormode(s) if self._entity_info.endpoint.has_attribute( None, clusters.ColorControl.Attributes.ColorMode @@ -325,19 +306,23 @@ class MatterLight(MatterEntity, LightEntity): & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported ): supported_color_modes.add(ColorMode.HS) + self._supports_color = True if ( capabilities & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported ): supported_color_modes.add(ColorMode.XY) + self._supports_color = True if ( capabilities & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported ): supported_color_modes.add(ColorMode.COLOR_TEMP) + self._supports_color_temperature = True + supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes LOGGER.debug( @@ -347,8 +332,17 @@ class MatterLight(MatterEntity, LightEntity): ) # set current values + self._attr_is_on = self.get_matter_attribute_value( + clusters.OnOff.Attributes.OnOff + ) - if self.supports_color: + if self._supports_brightness: + self._attr_brightness = self._get_brightness() + + if self._supports_color_temperature: + self._attr_color_temp = self._get_color_temperature() + + if self._supports_color: self._attr_color_mode = color_mode = self._get_color_mode() if ( ColorMode.HS in self._attr_supported_color_modes @@ -360,16 +354,12 @@ class MatterLight(MatterEntity, LightEntity): and color_mode == ColorMode.XY ): self._attr_xy_color = self._get_xy_color() - - if self.supports_color_temperature: - self._attr_color_temp = self._get_color_temperature() - - self._attr_is_on = self.get_matter_attribute_value( - clusters.OnOff.Attributes.OnOff - ) - - if self.supports_brightness: - self._attr_brightness = self._get_brightness() + elif self._attr_color_temp is not None: + self._attr_color_mode = ColorMode.COLOR_TEMP + elif self._attr_brightness is not None: + self._attr_color_mode = ColorMode.BRIGHTNESS + else: + self._attr_color_mode = ColorMode.ONOFF # Discovery schema(s) to map Matter Attributes to HA entities diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 848e89660ed..d3d0568342e 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==5.1.1"] + "requirements": ["python-matter-server==5.4.1"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index e7b18f308f7..90a9cb3fcc8 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, @@ -207,4 +209,56 @@ DISCOVERY_SCHEMAS = [ optional_attributes=(clusters.OnOff.Attributes.OnOff,), should_poll=True, ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="CarbonDioxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.CarbonDioxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM1Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm1ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM25Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm25ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM10Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm10ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), ] diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml deleted file mode 100644 index c72187b2ffe..00000000000 --- a/homeassistant/components/matter/services.yaml +++ /dev/null @@ -1,7 +0,0 @@ -open_commissioning_window: - fields: - device_id: - required: true - selector: - device: - integration: matter diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 2ef451b04a7..f3d302fc209 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -68,8 +68,12 @@ class MaxCubeClimate(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 39ce1f7a3bd..b657caceaff 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 @@ class MediaExtractor: 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_player/icons.json b/homeassistant/components/media_player/icons.json new file mode 100644 index 00000000000..e2769085833 --- /dev/null +++ b/homeassistant/components/media_player/icons.json @@ -0,0 +1,58 @@ +{ + "entity_component": { + "_": { + "default": "mdi:cast", + "state": { + "off": "mdi:cast-off", + "paused": "mdi:cast-connected", + "playing": "mdi:cast-connected" + } + }, + "receiver": { + "default": "mdi:audio-video", + "state": { + "off": "mdi:audio-video-off" + } + }, + "speaker": { + "default": "mdi:speaker", + "state": { + "off": "mdi:speaker-off", + "paused": "mdi:speaker-pause", + "playing": "mdi:speaker-play" + } + }, + "tv": { + "default": "mdi:television", + "state": { + "off": "mdi:television-off", + "paused": "mdi:television-pause", + "playing": "mdi:television-play" + } + } + }, + "services": { + "clear_playlist": "mdi:playlist-remove", + "join": "mdi:group", + "media_next_track": "mdi:skip-next", + "media_pause": "mdi:pause", + "media_play": "mdi:play", + "media_play_pause": "mdi:play-pause", + "media_previous_track": "mdi:skip-previous", + "media_seek": "mdi:fast-forward", + "media_stop": "mdi:stop", + "play_media": "mdi:play", + "repeat_set": "mdi:repeat", + "select_sound_mode": "mdi:surround-sound", + "select_source": "mdi:import", + "shuffle_set": "mdi:shuffle", + "toggle": "mdi:play-pause", + "turn_off": "mdi:power", + "turn_on": "mdi:power", + "unjoin": "mdi:ungroup", + "volume_down": "mdi:volume-minus", + "volume_mute": "mdi:volume-mute", + "volume_set": "mdi:volume", + "volume_up": "mdi:volume-plus" + } +} diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index d1ed5cafcbf..2fa7e87d737 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -9,59 +9,23 @@ from typing import Any from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant 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 -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] -CONF_LANGUAGE = "language" -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_TOKEN): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Establish connection with MELCloud.""" - if DOMAIN not in config: - return True - - username = config[DOMAIN][CONF_USERNAME] - token = config[DOMAIN][CONF_TOKEN] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: username, CONF_TOKEN: token}, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" @@ -159,11 +123,6 @@ class MelCloudDevice: via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), ) - @property - def daily_energy_consumed(self) -> float | None: - """Return energy consumed during the current day in kWh.""" - return self.device.daily_energy_consumed - async def mel_devices_setup( hass: HomeAssistant, token: str @@ -174,8 +133,8 @@ async def mel_devices_setup( all_devices = await get_devices( token, session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), + conf_update_interval=timedelta(minutes=30), + device_set_debounce=timedelta(seconds=2), ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9d2a4f08257..ed37ff76b76 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -114,6 +114,7 @@ class MelCloudClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: MelCloudDevice) -> None: """Initialize the climate.""" @@ -137,6 +138,8 @@ class AtaDeviceClimate(MelCloudClimate): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None: diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 9293c9bb3d5..9db44d5276c 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -13,49 +13,14 @@ import voluptuous as vol 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, FlowResult, FlowResultType +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 from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_create_import_issue( - hass: HomeAssistant, source: str, issue: str, success: bool = False -) -> None: - """Create issue from import.""" - if source != config_entries.SOURCE_IMPORT: - return - if not success: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{issue}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key=f"deprecated_yaml_import_issue_{issue}", - ) - return - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "MELCloud", - }, - ) - - class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -66,11 +31,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _create_entry(self, username: str, token: str) -> FlowResult: """Register new entry.""" await self.async_set_unique_id(username) - try: - self._abort_if_unique_id_configured({CONF_TOKEN: token}) - except AbortFlow: - await async_create_import_issue(self.hass, self.context["source"], "", True) - raise + self._abort_if_unique_id_configured({CONF_TOKEN: token}) return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_TOKEN: token} ) @@ -97,18 +58,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) except ClientResponseError as err: if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - await async_create_import_issue( - self.hass, self.context["source"], "invalid_auth" - ) return self.async_abort(reason="invalid_auth") - await async_create_import_issue( - self.hass, self.context["source"], "cannot_connect" - ) return self.async_abort(reason="cannot_connect") except (asyncio.TimeoutError, ClientError): - await async_create_import_issue( - self.hass, self.context["source"], "cannot_connect" - ) return self.async_abort(reason="cannot_connect") return await self._create_entry(username, acquired_token) @@ -127,15 +79,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] return await self._create_client(username, password=user_input[CONF_PASSWORD]) - 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] - ) - 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"]) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 8be40b22d9c..0122c840373 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -1,10 +1,10 @@ { "domain": "melcloud", "name": "MELCloud", - "codeowners": ["@vilppuvuorinen"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", "loggers": ["pymelcloud"], - "requirements": ["pymelcloud==2.5.8"] + "requirements": ["pymelcloud==2.5.9"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index cf53fe42b77..d3d1f4976f6 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -58,16 +58,6 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( @@ -90,16 +80,6 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 3abb30bf9ac..6a98b88e2d3 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -65,9 +65,6 @@ "room_temperature": { "name": "Room temperature" }, - "daily_energy": { - "name": "Daily energy consumed" - }, "outside_temperature": { "name": "Outside temperature" }, diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index 9facb18ed05..f94c3af6d9a 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -57,9 +57,13 @@ class MelissaClimate(ClimateEntity): _attr_hvac_modes = OP_MODES _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, serial_number, init_data): """Initialize the climate device.""" diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index 409cb9ae3ba..beb8b42a4a3 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -20,7 +20,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): +class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): # pylint: disable=hass-enforce-coordinator-module """Melnor data update coordinator.""" _device: Device @@ -42,7 +42,7 @@ class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): return self._device -class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): +class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """Base class for melnor entities.""" _device: Device diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index d36a9e58eb7..ac614e4691b 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import OptionsFlowWithConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, @@ -95,15 +96,11 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Init MetConfigFlowHandler.""" - self._errors: dict[str, Any] = {} - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - self._errors = {} + errors = {} if user_input is not None: if ( @@ -113,12 +110,12 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) - self._errors[CONF_NAME] = "already_configured" + errors[CONF_NAME] = "already_configured" return self.async_show_form( step_id="user", data_schema=_get_data_schema(self.hass), - errors=self._errors, + errors=errors, ) async def async_step_onboarding( @@ -146,14 +143,9 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler(config_entry) -class MetOptionsFlowHandler(config_entries.OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithConfigEntry): """Options flow for Met component.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize the Met OptionsFlow.""" - self._config_entry = config_entry - self._errors: dict[str, Any] = {} - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -171,5 +163,4 @@ class MetOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form( step_id="init", data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), - errors=self._errors, ) diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 042eb6f458f..5edecbbac0b 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,7 +1,8 @@ """The met_eireann component.""" from datetime import timedelta import logging -from typing import Self +from types import MappingProxyType +from typing import Any, Self import meteireann @@ -32,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b altitude=config_entry.data[CONF_ELEVATION], ) - weather_data = MetEireannWeatherData(hass, config_entry.data, raw_weather_data) + weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) async def _async_update_data() -> MetEireannWeatherData: """Fetch data from Met Éireann.""" @@ -70,14 +71,15 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" - def __init__(self, hass, config, weather_data): + def __init__( + self, config: MappingProxyType[str, Any], weather_data: meteireann.WeatherData + ) -> None: """Initialise the weather entity data.""" - self.hass = hass self._config = config self._weather_data = weather_data - self.current_weather_data = {} - self.daily_forecast = None - self.hourly_forecast = None + self.current_weather_data: dict[str, Any] = {} + self.daily_forecast: list[dict[str, Any]] = [] + self.hourly_forecast: list[dict[str, Any]] = [] async def fetch_data(self) -> Self: """Fetch data from API - (current weather and forecast).""" diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 909dd4ae955..b4c0102b97e 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -1,9 +1,11 @@ """Config flow to configure Met Éireann component.""" +from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME @@ -14,10 +16,10 @@ class MetEireannFlowHandler(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.""" - errors = {} - if user_input is not None: # Check if an identical entity is already configured await self.async_set_unique_id( @@ -41,6 +43,5 @@ class MetEireannFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ): int, } ), - errors=errors, ) return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 7602dca8343..84fc20cead7 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -88,7 +88,12 @@ class MetEireannWeather( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, coordinator, config, hourly): + def __init__( + self, + coordinator: DataUpdateCoordinator[MetEireannWeatherData], + config: MappingProxyType[str, Any], + hourly: bool, + ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) self._attr_unique_id = _calculate_unique_id(config, hourly) @@ -103,41 +108,41 @@ class MetEireannWeather( self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, + identifiers={(DOMAIN,)}, # type: ignore[arg-type] manufacturer="Met Éireann", model="Forecast", configuration_url="https://www.met.ie", ) @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return format_condition( self.coordinator.data.current_weather_data.get("condition") ) @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" return self.coordinator.data.current_weather_data.get("temperature") @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" return self.coordinator.data.current_weather_data.get("pressure") @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self.coordinator.data.current_weather_data.get("humidity") @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" return self.coordinator.data.current_weather_data.get("wind_speed") @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 6ad3868f13d..3f1cd2a5e34 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -4,15 +4,14 @@ import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -33,43 +32,6 @@ SCAN_INTERVAL = timedelta(minutes=15) CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CITY_SCHEMA]))}, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Meteo-France from legacy config file.""" - if not (conf := config.get(DOMAIN)): - return True - - for city_conf in conf: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Météo-France", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Meteo-France account from a config entry.""" @@ -79,17 +41,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] - async def _async_update_data_forecast_forecast(): + async def _async_update_data_forecast_forecast() -> Forecast: """Fetch data from API endpoint.""" return await hass.async_add_executor_job( client.get_forecast, latitude, longitude ) - async def _async_update_data_rain(): + async def _async_update_data_rain() -> Rain: """Fetch data from API endpoint.""" return await hass.async_add_executor_job(client.get_rain, latitude, longitude) - async def _async_update_data_alert(): + async def _async_update_data_alert() -> CurrentPhenomenons: """Fetch data from API endpoint.""" return await hass.async_add_executor_job( client.get_warning_current_phenomenoms, department, 0, True @@ -136,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, department, ) - if is_valid_warning_department(department): + if department is not None and is_valid_warning_department(department): if not hass.data[DOMAIN].get(department): coordinator_alert = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index ade6bedd362..a3001ee25c0 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -2,14 +2,17 @@ from __future__ import annotations import logging +from typing import Any from meteofrance_api.client import MeteoFranceClient +from meteofrance_api.model import Place import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CONF_CITY, DOMAIN @@ -21,12 +24,16 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Init MeteoFranceFlowHandler.""" - self.places = [] + self.places: list[Place] = [] @callback - def _show_setup_form(self, user_input=None, errors=None): + def _show_setup_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: """Show the setup form to the user.""" if user_input is None: @@ -40,9 +47,11 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - 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 initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is None: return self._show_setup_form(user_input, errors) @@ -72,15 +81,13 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, ) - async def async_step_import(self, user_input): - """Import a config entry.""" - return await self.async_step_user(user_input) - - async def async_step_cities(self, user_input=None): + async def async_step_cities( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Step where the user choose the city from the API search results.""" if not user_input: if len(self.places) > 1 and self.source != SOURCE_IMPORT: - places_for_form = {} + places_for_form: dict[str, str] = {} for place in self.places: places_for_form[_build_place_key(place)] = f"{place}" @@ -106,5 +113,5 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -def _build_place_key(place) -> str: +def _build_place_key(place: Place) -> str: return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 451d617e65b..c5ff38f2a87 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -303,7 +303,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor[Rain]): return dt_util.utc_from_timestamp(next_rain["dt"]) if next_rain else None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" reference_dt = self.coordinator.data.forecast[0]["dt"] return { @@ -330,7 +330,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): self._attr_unique_id = self._attr_name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state.""" return get_warning_text_status_from_indice_color( self.coordinator.data.get_domain_max_color() diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index d081a6e729b..79e35b6219f 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -110,7 +110,7 @@ class MeteoFranceWeather( ) @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id of the sensor.""" return self._unique_id diff --git a/homeassistant/components/meteoclimatic/manifest.json b/homeassistant/components/meteoclimatic/manifest.json index d7cc64727c8..31c97f9baf2 100644 --- a/homeassistant/components/meteoclimatic/manifest.json +++ b/homeassistant/components/meteoclimatic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteoclimatic", "iot_class": "cloud_polling", "loggers": ["meteoclimatic"], - "requirements": ["pymeteoclimatic==0.0.6"] + "requirements": ["pymeteoclimatic==0.1.0"] } diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index 9291f22f3b7..401f2c9d265 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -3,6 +3,7 @@ "name": "Met Office", "codeowners": ["@MrHarcombe", "@avee87"], "config_flow": true, + "disabled": "Integration library not compatible with Python 3.12", "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index d03e46a1d0b..44d60d5dcb4 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -243,7 +243,7 @@ class MikrotikData: return [] -class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): +class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Mikrotik Hub Object.""" def __init__( diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 0482e573766..136c4a2940f 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -class MillDataUpdateCoordinator(DataUpdateCoordinator): +class MillDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Mill data.""" def __init__( diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index a5e59b4f8ec..a2e70b8f9c8 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,4 +1,5 @@ """Support for mill wifi-enabled home heaters.""" + from typing import Any import mill @@ -92,9 +93,14 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: MillDataUpdateCoordinator, heater: mill.Heater @@ -181,9 +187,14 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index fc872d37bde..d86f8453413 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -89,7 +89,7 @@ class MinecraftServer: self._server.timeout = DATA_UPDATE_TIMEOUT _LOGGER.debug( - "%s server instance created with address '%s'", + "Initialized %s server instance with address '%s'", self._server_type, self._address, ) @@ -98,7 +98,15 @@ class MinecraftServer: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: + _LOGGER.debug( + "Connection check of %s server failed: %s", + self._server_type, + self._get_error_message(error), + ) return False return True @@ -108,7 +116,9 @@ class MinecraftServer: status_response: BedrockStatusResponse | JavaStatusResponse if self._server is None: - raise MinecraftServerNotInitializedError() + raise MinecraftServerNotInitializedError( + f"Server instance with address '{self._address}' is not initialized" + ) try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) @@ -128,7 +138,7 @@ class MinecraftServer: self, status_response: JavaStatusResponse ) -> MinecraftServerData: """Extract Java Edition server data out of status response.""" - players_list = [] + players_list: list[str] = [] if players := status_response.players.sample: for player in players: diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 045133421fb..022b7ed3991 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Minecraft Server integration.""" +from __future__ import annotations + import logging +from typing import Any import voluptuous as vol @@ -20,9 +23,11 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 3 - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input: address = user_input[CONF_ADDRESS] @@ -39,17 +44,17 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): try: await api.async_initialize() - except MinecraftServerAddressError: - pass + except MinecraftServerAddressError as error: + _LOGGER.debug( + "Initialization of %s server failed: %s", + server_type, + error, + ) else: if await api.async_is_online(): config_data[CONF_TYPE] = server_type return self.async_create_entry(title=address, data=config_data) - _LOGGER.debug( - "Connection check to %s server '%s' failed", server_type, address - ) - # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" @@ -57,7 +62,11 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) - def _show_config_form(self, user_input=None, errors=None) -> FlowResult: + def _show_config_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: """Show the setup form to the user.""" if user_input is None: user_input = {} diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 671bbdb7a05..606d6085fda 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -61,7 +61,7 @@ def get_extra_state_attributes_players_list( data: MinecraftServerData, ) -> dict[str, list[str]]: """Return players list as extra state attributes, if available.""" - extra_state_attributes = {} + extra_state_attributes: dict[str, Any] = {} players_list = data.players_list if players_list is not None and len(players_list) != 0: diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 124ef750baa..831d2d5cdfb 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -39,7 +39,7 @@ from .http_api import RegistrationsView from .util import async_create_cloud_hook from .webhook import handle_webhook -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 69ecb913c98..2be71965371 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -70,13 +70,14 @@ async def async_setup_entry( class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): """Representation of an mobile app binary sensor.""" - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._config[ATTR_SENSOR_STATE] - - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON + self._async_update_attr_from_config() + + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + super()._async_update_attr_from_config() + self._attr_is_on = self._config[ATTR_SENSOR_STATE] diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 120014d1d52..76cf22cef54 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE -from homeassistant.core import callback +from homeassistant.core import State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -32,9 +32,23 @@ class MobileAppEntity(RestoreEntity): self._entry = entry self._registration = entry.data self._attr_unique_id = config[CONF_UNIQUE_ID] - self._name = self._config[CONF_NAME] + self._attr_entity_registry_enabled_default = not config.get( + ATTR_SENSOR_DISABLED + ) + self._attr_name = config[CONF_NAME] + self._async_update_attr_from_config() - async def async_added_to_hass(self): + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + config = self._config + self._attr_device_class = config.get(ATTR_SENSOR_DEVICE_CLASS) + self._attr_extra_state_attributes = config[ATTR_SENSOR_ATTRIBUTES] + self._attr_icon = config[ATTR_SENSOR_ICON] + self._attr_entity_category = config.get(ATTR_SENSOR_ENTITY_CATEGORY) + self._attr_available = config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE + + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -49,58 +63,25 @@ class MobileAppEntity(RestoreEntity): await self.async_restore_last_state(state) - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - self._config[ATTR_SENSOR_STATE] = last_state.state - self._config[ATTR_SENSOR_ATTRIBUTES] = { + config = self._config + config[ATTR_SENSOR_STATE] = last_state.state + config[ATTR_SENSOR_ATTRIBUTES] = { **last_state.attributes, **self._config[ATTR_SENSOR_ATTRIBUTES], } if ATTR_ICON in last_state.attributes: - self._config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] - - @property - def name(self): - """Return the name of the mobile app sensor.""" - return self._name - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if entity should be enabled by default.""" - return not self._config.get(ATTR_SENSOR_DISABLED) - - @property - def device_class(self): - """Return the device class.""" - return self._config.get(ATTR_SENSOR_DEVICE_CLASS) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._config[ATTR_SENSOR_ATTRIBUTES] - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._config[ATTR_SENSOR_ICON] - - @property - def entity_category(self): - """Return the entity category, if any.""" - return self._config.get(ATTR_SENSOR_ENTITY_CATEGORY) + config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] @property def device_info(self): """Return device registry information for this entity.""" return device_info(self._registration) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE - @callback def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" self._config.update(data) + self._async_update_attr_from_config() self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index fc325b1b6e9..fd712faf121 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -2,12 +2,12 @@ from __future__ import annotations from datetime import date, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -79,26 +79,28 @@ async def async_setup_entry( class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) - + config = self._config if not (last_sensor_data := await self.async_get_last_sensor_data()): # Workaround to handle migration to RestoreSensor, can be removed # in HA Core 2023.4 - self._config[ATTR_SENSOR_STATE] = None + config[ATTR_SENSOR_STATE] = None webhook_id = self._entry.data[CONF_WEBHOOK_ID] + if TYPE_CHECKING: + assert self.unique_id is not None sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) if ( self.device_class == SensorDeviceClass.TEMPERATURE and sensor_unique_id == "battery_temperature" ): - self._config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS - return + config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS + else: + config[ATTR_SENSOR_STATE] = last_sensor_data.native_value + config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement - self._config[ATTR_SENSOR_STATE] = last_sensor_data.native_value - self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement + self._async_update_attr_from_config() @property def native_value(self) -> StateType | date | datetime: @@ -106,29 +108,25 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor): if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN): return None + device_class = self.device_class + if ( - self.device_class - in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ) + device_class in (SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP) # Only parse strings: if the sensor's state is restored, the state is a # native date or datetime, not str and isinstance(state, str) and (timestamp := dt_util.parse_datetime(state)) is not None ): - if self.device_class == SensorDeviceClass.DATE: + if device_class == SensorDeviceClass.DATE: return timestamp.date() return timestamp return state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement this sensor expresses itself in.""" - return self._config.get(ATTR_SENSOR_UOM) - - @property - def state_class(self) -> str | None: - """Return state class.""" - return self._config.get(ATTR_SENSOR_STATE_CLASS) + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + super()._async_update_attr_from_config() + config = self._config + self._attr_native_unit_of_measurement = config.get(ATTR_SENSOR_UOM) + self._attr_state_class = config.get(ATTR_SENSOR_STATE_CLASS) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cc1b3c74356..0f674d4d0df 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -133,12 +133,10 @@ from .const import ( # noqa: F401 ) from .modbus import ModbusHub, async_modbus_setup from .validators import ( - duplicate_entity_validator, + check_config, duplicate_fan_mode_validator, - duplicate_modbus_validator, nan_validator, - number_validator, - scan_interval_validator, + register_int_list_validator, struct_validator, ) @@ -188,8 +186,8 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( ] ), 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_SCALE, default=1): cv.positive_float, + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( CONF_SWAP, @@ -243,8 +241,8 @@ CLIMATE_SCHEMA = vol.All( { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): number_validator, - vol.Optional(CONF_MIN_TEMP, default=5): number_validator, + vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float, + vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, @@ -281,7 +279,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { - CONF_ADDRESS: cv.positive_int, + vol.Required(CONF_ADDRESS): register_int_list_validator, CONF_FAN_MODE_VALUES: { vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, @@ -344,10 +342,10 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_MIN_VALUE): number_validator, - vol.Optional(CONF_MAX_VALUE): number_validator, + vol.Optional(CONF_MIN_VALUE): cv.positive_float, + vol.Optional(CONF_MAX_VALUE): cv.positive_float, vol.Optional(CONF_NAN_VALUE): nan_validator, - vol.Optional(CONF_ZERO_SUPPRESS): number_validator, + vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float, } ), ) @@ -417,12 +415,10 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( cv.ensure_list, - scan_interval_validator, - duplicate_entity_validator, - duplicate_modbus_validator, [ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), ], + check_config, ), }, extra=vol.ALLOW_EXTRA, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index d3ec06bbdd7..cdc1e7a6986 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -96,10 +96,6 @@ class BasePlatform(Entity): "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" ) @@ -186,12 +182,23 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._scale = config[CONF_SCALE] - 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 ) self._slave_size = self._count = config[CONF_COUNT] + self._value_is_int: bool = self._data_type in ( + DataType.INT16, + DataType.INT32, + DataType.INT64, + DataType.UINT16, + DataType.UINT32, + DataType.UINT64, + ) + if not self._value_is_int: + self._precision = config.get(CONF_PRECISION, 2) + else: + self._precision = config.get(CONF_PRECISION, 0) def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: """Do swap as needed.""" @@ -226,13 +233,13 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): return None val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: - return str(self._min_value) + val = self._min_value if self._max_value is not None and val > self._max_value: - return str(self._max_value) + val = self._max_value if self._zero_suppress is not None and abs(val) <= self._zero_suppress: return "0" if self._precision == 0: - return str(int(round(val, 0))) + return str(round(val)) return f"{float(val):.{self._precision}f}" def unpack_structure_result(self, registers: list[int]) -> str | None: diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 76132014413..637478fffd4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -97,7 +97,12 @@ async def async_setup_platform( class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Representation of a Modbus Thermostat.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -125,7 +130,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): ) 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: @@ -171,7 +175,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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), @@ -254,16 +257,23 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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, - ) + if isinstance(self._fan_mode_register, list): + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register[0], + [value], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) await self.async_update() @@ -345,7 +355,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # 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 + CALL_TYPE_REGISTER_HOLDING, + self._fan_mode_register + if isinstance(self._fan_mode_register, int) + else self._fan_mode_register[0], + raw=True, ) # Translate the value received diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 93a3f22c97d..194eb56757e 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.4"] + "requirements": ["pymodbus==3.6.3"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 95c0cd45332..71631352d52 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -7,12 +7,8 @@ from collections.abc import Callable import logging from typing import Any -from pymodbus.client import ( - ModbusBaseClient, - ModbusSerialClient, - ModbusTcpClient, - ModbusUdpClient, -) +from pymodbus.client import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.client.base import ModbusBaseClient from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index c015d117b13..4f2a1d6dc76 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -125,7 +125,10 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): if self._coordinator: if result: result_array = list( - map(float if self._precision else int, result.split(",")) + map( + float if not self._value_is_int else int, + result.split(","), + ) ) self._attr_native_value = result_array[0] self._coordinator.async_set_updated_data(result_array) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 5e2129bd90a..76d8e270ffe 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -172,23 +172,6 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: } -def number_validator(value: Any) -> int | float: - """Coerce a value to number without losing precision.""" - if isinstance(value, int): - return value - if isinstance(value, float): - return value - - try: - return int(value) - except (TypeError, ValueError): - pass - try: - return float(value) - except (TypeError, ValueError) as err: - raise vol.Invalid(f"invalid number {value}") from err - - def nan_validator(value: Any) -> int: """Convert nan string to number (can be hex string or int).""" if isinstance(value, int): @@ -203,6 +186,23 @@ def nan_validator(value: Any) -> int: raise vol.Invalid(f"invalid number {value}") from err +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: + warn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(warn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config + + def scan_interval_validator(config: dict) -> dict: """Control scan_interval.""" for hub in config: @@ -276,7 +276,11 @@ def duplicate_entity_validator(config: dict) -> dict: 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( + entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS] + if isinstance(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS], int) + else entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS][0] + ) a += "_" + str(inx) entry_addrs.add(a) @@ -306,7 +310,7 @@ def duplicate_entity_validator(config: dict) -> dict: return config -def duplicate_modbus_validator(config: list) -> list: +def duplicate_modbus_validator(config: dict) -> dict: """Control modbus connection for duplicates.""" hosts: set[str] = set() names: set[str] = set() @@ -334,18 +338,23 @@ def duplicate_modbus_validator(config: list) -> list: 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) +def register_int_list_validator(value: Any) -> Any: + """Check if a register (CONF_ADRESS) is an int or a list having only 1 register.""" + if isinstance(value, int) and value >= 0: + return value - for key in reversed(errors): - del config[CONF_FAN_MODE_VALUES][key] - return config + if isinstance(value, list): + if (len(value) == 1) and isinstance(value[0], int) and value[0] >= 0: + return value + + raise vol.Invalid( + f"Invalid {CONF_ADDRESS} register for fan mode. Required type: positive integer, allowed 1 or list of 1 register." + ) + + +def check_config(config: dict) -> dict: + """Do final config check.""" + config2 = duplicate_modbus_validator(config) + config3 = scan_interval_validator(config2) + config4 = duplicate_entity_validator(config3) + return config4 diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index fafd7f9c8d2..5997b2aa846 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -1,8 +1,10 @@ """The Modern Forms integration.""" from __future__ import annotations +from collections.abc import Callable, Coroutine from datetime import timedelta import logging +from typing import Any, Concatenate, ParamSpec, TypeVar from aiomodernforms import ( ModernFormsConnectionError, @@ -24,11 +26,16 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN +_ModernFormsDeviceEntityT = TypeVar( + "_ModernFormsDeviceEntityT", bound="ModernFormsDeviceEntity" +) +_P = ParamSpec("_P") + SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ Platform.BINARY_SENSOR, - Platform.LIGHT, Platform.FAN, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, ] @@ -64,14 +71,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def modernforms_exception_handler(func): +def modernforms_exception_handler( + func: Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Any], +) -> Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Modern Forms calls to handle Modern Forms exceptions. A decorator that wraps the passed in function, catches Modern Forms errors, and handles the availability of the device in the data coordinator. """ - async def handler(self, *args, **kwargs): + async def handler( + self: _ModernFormsDeviceEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() @@ -87,7 +98,7 @@ def modernforms_exception_handler(func): return handler -class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): +class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Modern Forms data from single endpoint.""" def __init__( diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 4992ecf34a7..4a4c57b676e 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -8,7 +8,7 @@ import aiohttp from moehlenhoff_alpha2 import Alpha2Base from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -17,14 +17,14 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] UPDATE_INTERVAL = timedelta(seconds=60) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - base = Alpha2Base(entry.data["host"]) + base = Alpha2Base(entry.data[CONF_HOST]) coordinator = Alpha2BaseCoordinator(hass, base) await coordinator.async_config_entry_first_refresh() @@ -52,7 +52,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): +class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): # pylint: disable=hass-enforce-coordinator-module """Keep the base instance in one place and centralize the update.""" def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 1868be11f67..063628d6d32 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -35,7 +35,6 @@ async def async_setup_entry( ) -# https://developers.home-assistant.io/docs/core/entity/climate/ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): """Alpha2 ClimateEntity.""" @@ -47,40 +46,34 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None: """Initialize Alpha2 ClimateEntity.""" super().__init__(coordinator) self.heat_area_id = heat_area_id self._attr_unique_id = heat_area_id - self._attr_name = self.coordinator.data["heat_areas"][heat_area_id][ - "HEATAREA_NAME" - ] + self._attr_name = self.heat_area["HEATAREA_NAME"] + + @property + def heat_area(self) -> dict[str, Any]: + """Return the heat area.""" + return self.coordinator.data["heat_areas"][self.heat_area_id] @property def min_temp(self) -> float: """Return the minimum temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get( - "T_TARGET_MIN", 0.0 - ) - ) + return float(self.heat_area.get("T_TARGET_MIN", 0.0)) @property def max_temp(self) -> float: """Return the maximum temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get( - "T_TARGET_MAX", 30.0 - ) - ) + return float(self.heat_area.get("T_TARGET_MAX", 30.0)) @property def current_temperature(self) -> float: """Return the current temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get("T_ACTUAL", 0.0) - ) + return float(self.heat_area.get("T_ACTUAL", 0.0)) @property def hvac_mode(self) -> HVACMode: @@ -96,9 +89,7 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" - if not self.coordinator.data["heat_areas"][self.heat_area_id][ - "_HEATCTRL_STATE" - ]: + if not self.heat_area["_HEATCTRL_STATE"]: return HVACAction.IDLE if self.coordinator.get_cooling(): return HVACAction.COOLING @@ -107,9 +98,7 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get("T_TARGET", 0.0) - ) + return float(self.heat_area.get("T_TARGET", 0.0)) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -123,9 +112,9 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def preset_mode(self) -> str: """Return the current preset mode.""" - if self.coordinator.data["heat_areas"][self.heat_area_id]["HEATAREA_MODE"] == 1: + if self.heat_area["HEATAREA_MODE"] == 1: return PRESET_DAY - if self.coordinator.data["heat_areas"][self.heat_area_id]["HEATAREA_MODE"] == 2: + if self.heat_area["HEATAREA_MODE"] == 2: return PRESET_NIGHT return PRESET_AUTO diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py index cafdca040b3..d2d14f27552 100644 --- a/homeassistant/components/moehlenhoff_alpha2/config_flow.py +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -1,27 +1,30 @@ """Alpha2 config flow.""" import asyncio import logging +from typing import Any import aiohttp from moehlenhoff_alpha2 import Alpha2Base import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -async def validate_input(data): +async def validate_input(data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ - base = Alpha2Base(data["host"]) + base = Alpha2Base(data[CONF_HOST]) try: await base.update_data() except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): @@ -34,16 +37,18 @@ async def validate_input(data): return {"title": base.name} -class Alpha2BaseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class Alpha2BaseConfigFlow(ConfigFlow, domain=DOMAIN): """Möhlenhoff Alpha2 config flow.""" 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.""" errors = {} if user_input is not None: - self._async_abort_entries_match({"host": user_input["host"]}) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) result = await validate_input(user_input) if result.get("error"): errors["base"] = result["error"] diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 766af715485..69452bf1fec 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -13,6 +13,12 @@ "manufacturer_id": 89, "manufacturer_data_start": [8], "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [12], + "connectable": false } ], "codeowners": ["@bdraco"], diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 833d2640202..c987e1bb10a 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -51,6 +51,7 @@ POSITION_DEVICE_MAP = { BlindType.CurtainLeft: CoverDeviceClass.CURTAIN, BlindType.CurtainRight: CoverDeviceClass.CURTAIN, BlindType.SkylightBlind: CoverDeviceClass.SHADE, + BlindType.InsectScreen: CoverDeviceClass.SHADE, } TILT_DEVICE_MAP = { @@ -69,6 +70,7 @@ TILT_ONLY_DEVICE_MAP = { TDBU_DEVICE_MAP = { BlindType.TopDownBottomUp: CoverDeviceClass.SHADE, + BlindType.TriangleBlind: CoverDeviceClass.BLIND, } diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index f9115cd8146..6f7b7dfae38 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.19"] + "requirements": ["motionblinds==0.6.20"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index e71abe09069..dddcb0e00fd 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,6 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_WIFI +from motionblinds.motion_blinds import DEVICE_TYPE_TDBU from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -29,7 +30,7 @@ async def async_setup_entry( for blind in motion_gateway.device_list.values(): entities.append(MotionSignalStrengthSensor(coordinator, blind)) - if blind.type == BlindType.TopDownBottomUp: + if blind.device_type == DEVICE_TYPE_TDBU: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) elif blind.battery_voltage is not None and blind.battery_voltage > 0: diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 8baceb104c3..6f62a0731b6 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -14,7 +14,10 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, ] diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py new file mode 100644 index 00000000000..6bbed2e90c5 --- /dev/null +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -0,0 +1,39 @@ +"""Support for MotionMount binary sensors.""" +import motionmount + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import 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 = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([MotionMountMovingSensor(mm, entry)]) + + +class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): + """The moving sensor of a MotionMount.""" + + _attr_device_class = BinarySensorDeviceClass.MOVING + _attr_translation_key = "motionmount_is_moving" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize moving binary sensor entity.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-moving" + + @property + def is_on(self) -> bool: + """Get on status.""" + return self.mm.is_moving or False diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py index 92045193ad6..884904332af 100644 --- a/homeassistant/components/motionmount/const.py +++ b/homeassistant/components/motionmount/const.py @@ -3,3 +3,4 @@ DOMAIN = "motionmount" EMPTY_MAC = "00:00:00:00:00:00" +WALL_PRESET_NAME = "0_wall" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py new file mode 100644 index 00000000000..ef0b1e918ae --- /dev/null +++ b/homeassistant/components/motionmount/select.py @@ -0,0 +1,60 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +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, WALL_PRESET_NAME +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 = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([MotionMountPresets(mm, entry)], True) + + +class MotionMountPresets(MotionMountEntity, SelectEntity): + """The presets of a MotionMount.""" + + _attr_translation_key = "motionmount_preset" + _attr_current_option: str | None = None + + def __init__( + self, + mm: motionmount.MotionMount, + config_entry: ConfigEntry, + ) -> None: + """Initialize Preset selector.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-preset" + + def _update_options(self, presets: dict[int, str]) -> None: + """Convert presets to select options.""" + options = [WALL_PRESET_NAME] + for index, name in presets.items(): + options.append(f"{index}: {name}") + + self._attr_options = options + + async def async_update(self) -> None: + """Get latest state from MotionMount.""" + presets = await self.mm.get_presets() + self._update_options(presets) + + if self._attr_current_option is None: + self._attr_current_option = self._attr_options[0] + + async def async_select_option(self, option: str) -> None: + """Set the new option.""" + index = int(option[:1]) + await self.mm.go_to_preset(index) + self._attr_current_option = option + + # Perform an update so we detect changes to the presets (changes are not pushed) + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py new file mode 100644 index 00000000000..ed3cbd7d38b --- /dev/null +++ b/homeassistant/components/motionmount/sensor.py @@ -0,0 +1,46 @@ +"""Support for MotionMount sensors.""" +import motionmount + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +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 = hass.data[DOMAIN][entry.entry_id] + + async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) + + +class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): + """The error status sensor of a MotionMount.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["none", "motor", "internal"] + _attr_translation_key = "motionmount_error_status" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize sensor entiry.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-error-status" + + @property + def native_value(self) -> str: + """Return error status.""" + errors = self.mm.error_status or 0 + + if errors & (1 << 31): + # Only when but 31 is set are there any errors active at this moment + if errors & (1 << 10): + return "motor" + + return "internal" + + return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 00a409f3058..39f7c53db35 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "binary_sensor": { + "motionmount_is_moving": { + "name": "Moving" + } + }, "number": { "motionmount_extension": { "name": "Extension" @@ -32,6 +37,24 @@ "motionmount_turn": { "name": "Turn" } + }, + "sensor": { + "motionmount_error_status": { + "name": "Error Status", + "state": { + "none": "None", + "motor": "Motor", + "internal": "Internal" + } + } + }, + "select": { + "motionmount_preset": { + "name": "Preset", + "state": { + "0_wall": "0: Wall" + } + } } } } diff --git a/homeassistant/components/mpd/manifest.json b/homeassistant/components/mpd/manifest.json index dc88443fb51..e03005fb95a 100644 --- a/homeassistant/components/mpd/manifest.json +++ b/homeassistant/components/mpd/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/mpd", "iot_class": "local_polling", "loggers": ["mpd"], - "requirements": ["python-mpd2==3.0.5"] + "requirements": ["python-mpd2==3.1.1"] } diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 64d8c27f1de..5fadf6ba590 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -22,10 +22,6 @@ ABBREVIATIONS = { "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", "clr_temp_cmd_tpl": "color_temp_command_template", - "bat_lev_t": "battery_level_topic", - "bat_lev_tpl": "battery_level_template", - "chrg_t": "charging_topic", - "chrg_tpl": "charging_template", "clrm": "color_mode", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", @@ -33,8 +29,6 @@ ABBREVIATIONS = { "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", - "cln_t": "cleaning_topic", - "cln_tpl": "cleaning_template", "cmd_off_tpl": "command_off_template", "cmd_on_tpl": "command_on_template", "cmd_t": "command_topic", @@ -54,19 +48,13 @@ ABBREVIATIONS = { "dir_cmd_tpl": "direction_command_template", "dir_stat_t": "direction_state_topic", "dir_val_tpl": "direction_value_template", - "dock_t": "docked_topic", - "dock_tpl": "docked_template", "dock_cmd_t": "dock_command_topic", "dock_cmd_tpl": "dock_command_template", "e": "encoding", "en": "enabled_by_default", "ent_cat": "entity_category", "ent_pic": "entity_picture", - "err_t": "error_topic", - "err_tpl": "error_template", "evt_typ": "event_types", - "fanspd_t": "fan_speed_topic", - "fanspd_tpl": "fan_speed_template", "fanspd_lst": "fan_speed_list", "flsh_tlng": "flash_time_long", "flsh_tsht": "flash_time_short", @@ -160,7 +148,6 @@ ABBREVIATIONS = { "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", "pl_strt": "payload_start", - "pl_stpa": "payload_start_pause", "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", "pl_ton": "payload_turn_on", @@ -284,6 +271,7 @@ DEVICE_ABBREVIATIONS = { "hw": "hw_version", "sw": "sw_version", "sa": "suggested_area", + "sn": "serial_number", } ORIGIN_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 9143b804c60..7ab2e9ebf90 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,7 +42,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -56,7 +55,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -68,15 +67,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = vol.All( - validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=True), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN = vol.All( - validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=False), - _PLATFORM_SCHEMA_BASE, -) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index c87d4c9244a..164632cdd10 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -35,11 +35,9 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception from .const import ( @@ -94,10 +92,6 @@ SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 -MQTT_ENTRIES_NAMING_BLOG_URL = ( - "https://developers.home-assistant.io/blog/2023-057-21-change-naming-mqtt-entities/" -) - SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -222,7 +216,11 @@ def subscribe( def remove() -> None: """Remove listener convert.""" - run_callback_threadsafe(hass.loop, async_remove).result() + # MQTT messages tend to be high volume, + # and since they come in via a thread and need to be processed in the event loop, + # we want to avoid hass.add_job since most of the time is spent calling + # inspect to figure out how to run the callback. + hass.loop.call_soon_threadsafe(async_remove) return remove @@ -419,13 +417,12 @@ class MQTT: ) self._pending_unsubscribes: set[str] = set() # topic - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: self._ha_started.set() else: @callback def ha_started(_: Event) -> None: - self.register_naming_issues() self._ha_started.set() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) @@ -438,25 +435,6 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) - def register_naming_issues(self) -> None: - """Register issues with MQTT entity naming.""" - mqtt_data = get_mqtt_data(self.hass) - for issue_key, items in mqtt_data.issues.items(): - config_list = "\n".join([f"- {item}" for item in items]) - async_create_issue( - self.hass, - DOMAIN, - issue_key, - breaks_in_ha_version="2024.2.0", - is_fixable=False, - translation_key=issue_key, - translation_placeholders={ - "config": config_list, - }, - learn_more_url=MQTT_ENTRIES_NAMING_BLOG_URL, - severity=IssueSeverity.WARNING, - ) - def start( self, mqtt_data: MqttData, @@ -822,6 +800,10 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: """Message received callback.""" + # MQTT messages tend to be high volume, + # and since they come in via a thread and need to be processed in the event loop, + # we want to avoid hass.add_job since most of the time is spent calling + # inspect to figure out how to run the callback. self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg) @lru_cache(None) # pylint: disable=method-cache-max-size-none diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 65ffd4d17c0..94311eeda61 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -610,6 +610,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED _attr_target_temperature_low: float | None = None _attr_target_temperature_high: float | None = None + _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> vol.Schema: @@ -703,7 +704,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): config.get(key), entity=self ).async_render - support = ClimateEntityFeature(0) + support = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or ( self._topic[CONF_TEMP_COMMAND_TOPIC] is not None ): @@ -989,23 +990,17 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set a preset mode.""" - if self._feature_preset_mode and self.preset_modes: - if preset_mode not in self.preset_modes and preset_mode is not PRESET_NONE: - _LOGGER.warning("'%s' is not a valid preset mode", preset_mode) - return - mqtt_payload = self._command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE]( - preset_mode - ) - await self._publish( - CONF_PRESET_MODE_COMMAND_TOPIC, - mqtt_payload, - ) + mqtt_payload = self._command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE]( + preset_mode + ) + await self._publish( + CONF_PRESET_MODE_COMMAND_TOPIC, + mqtt_payload, + ) - if self._optimistic_preset_mode: - self._attr_preset_mode = preset_mode - self.async_write_ha_state() - - return + if self._optimistic_preset_mode: + self._attr_preset_mode = preset_mode + self.async_write_ha_state() # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 50ea3860d9e..fba2f13937e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,4 +1,5 @@ """Constants used by multiple MQTT modules.""" + from homeassistant.const import CONF_PAYLOAD, Platform ATTR_DISCOVERY_HASH = "discovery_hash" @@ -7,6 +8,7 @@ ATTR_DISCOVERY_TOPIC = "discovery_topic" ATTR_PAYLOAD = "payload" ATTR_QOS = "qos" ATTR_RETAIN = "retain" +ATTR_SERIAL_NUMBER = "serial_number" ATTR_TOPIC = "topic" CONF_AVAILABILITY = "availability" @@ -73,6 +75,7 @@ CONF_CONNECTIONS = "connections" CONF_MANUFACTURER = "manufacturer" CONF_HW_VERSION = "hw_version" CONF_SW_VERSION = "sw_version" +CONF_SERIAL_NUMBER = "serial_number" CONF_VIA_DEVICE = "via_device" CONF_DEPRECATED_VIA_HUB = "via_hub" CONF_SUGGESTED_AREA = "suggested_area" @@ -142,9 +145,9 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, + Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, - Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, @@ -152,8 +155,8 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, - Platform.SELECT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index fc7528743fa..b6d505d7c98 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -34,7 +34,7 @@ from .const import ( CONF_TOPIC, DOMAIN, ) -from .discovery import MQTTDiscoveryPayload +from .discovery import MQTTDiscoveryPayload, clear_discovery_hash from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, MqttDiscoveryDeviceUpdate, @@ -62,10 +62,13 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( vol.Required(CONF_PLATFORM): DEVICE, vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_DISCOVERY_ID): str, + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # By default, a MQTT device trigger now will be referenced by + # device_id, type and subtype instead. + vol.Optional(CONF_DISCOVERY_ID): str, vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_SUBTYPE): cv.string, - } + }, ) TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( @@ -123,6 +126,7 @@ class Trigger: device_id: str = attr.ib() discovery_data: DiscoveryInfoType | None = attr.ib() + discovery_id: str | None = attr.ib() hass: HomeAssistant = attr.ib() payload: str | None = attr.ib() qos: int | None = attr.ib() @@ -202,6 +206,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): self.discovery_data = discovery_data self.hass = hass self._mqtt_data = get_mqtt_data(hass) + self.trigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" MqttDiscoveryDeviceUpdate.__init__( self, @@ -216,11 +221,19 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): """Initialize the device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] discovery_id = discovery_hash[1] - if discovery_id not in self._mqtt_data.device_triggers: - self._mqtt_data.device_triggers[discovery_id] = Trigger( + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # To make sure old automation keep working we determine the trigger_id + # based on the discovery_id if it is set. + for trigger_id, trigger in self._mqtt_data.device_triggers.items(): + if trigger.discovery_id == discovery_id: + self.trigger_id = trigger_id + break + if self.trigger_id not in self._mqtt_data.device_triggers: + self._mqtt_data.device_triggers[self.trigger_id] = Trigger( hass=self.hass, device_id=self.device_id, discovery_data=self.discovery_data, + discovery_id=discovery_id, type=self._config[CONF_TYPE], subtype=self._config[CONF_SUBTYPE], topic=self._config[CONF_TOPIC], @@ -229,7 +242,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): value_template=self._config[CONF_VALUE_TEMPLATE], ) else: - await self._mqtt_data.device_triggers[discovery_id].update_trigger( + await self._mqtt_data.device_triggers[self.trigger_id].update_trigger( self._config ) debug_info.add_trigger_discovery_data( @@ -239,22 +252,39 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None: """Handle MQTT device trigger discovery updates.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] - discovery_id = discovery_hash[1] debug_info.update_trigger_discovery_data( self.hass, discovery_hash, discovery_data ) config = TRIGGER_DISCOVERY_SCHEMA(discovery_data) + new_trigger_id = f"{self.device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" + if new_trigger_id != self.trigger_id: + mqtt_data = get_mqtt_data(self.hass) + if new_trigger_id in mqtt_data.device_triggers: + _LOGGER.error( + "Cannot update device trigger %s due to an existing duplicate " + "device trigger with the same device_id, " + "type and subtype. Got: %s", + discovery_hash, + config, + ) + return + # Update trigger_id based index after update of type or subtype + mqtt_data.device_triggers[new_trigger_id] = mqtt_data.device_triggers.pop( + self.trigger_id + ) + self.trigger_id = new_trigger_id + update_device(self.hass, self._config_entry, config) - device_trigger: Trigger = self._mqtt_data.device_triggers[discovery_id] + device_trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] await device_trigger.update_trigger(config) async def async_tear_down(self) -> None: """Cleanup device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] - discovery_id = discovery_hash[1] - if discovery_id in self._mqtt_data.device_triggers: + if self.trigger_id in self._mqtt_data.device_triggers: _LOGGER.info("Removing trigger: %s", discovery_hash) - trigger: Trigger = self._mqtt_data.device_triggers[discovery_id] + trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] + trigger.discovery_data = None trigger.detach_trigger() debug_info.remove_trigger_discovery_data(self.hass, discovery_hash) @@ -267,7 +297,30 @@ async def async_setup_trigger( ) -> None: """Set up the MQTT device trigger.""" config = TRIGGER_DISCOVERY_SCHEMA(config) + + # We update the device based on the trigger config to obtain the device_id. + # In all cases the setup will lead to device entry to be created or updated. + # If the trigger is a duplicate, trigger creation will be cancelled but we allow + # the device data to be updated to not add additional complexity to the code. device_id = update_device(hass, config_entry, config) + discovery_id = discovery_data[ATTR_DISCOVERY_HASH][1] + trigger_type = config[CONF_TYPE] + trigger_subtype = config[CONF_SUBTYPE] + trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" + mqtt_data = get_mqtt_data(hass) + if ( + trigger_id in mqtt_data.device_triggers + and mqtt_data.device_triggers[trigger_id].discovery_data is not None + ): + _LOGGER.error( + "Config for device trigger %s conflicts with existing " + "device trigger, cannot set up trigger, got: %s", + discovery_id, + config, + ) + send_discovery_done(hass, discovery_data) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) + return None if TYPE_CHECKING: assert isinstance(device_id, str) @@ -283,8 +336,9 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None mqtt_data = get_mqtt_data(hass) triggers = await async_get_triggers(hass, device_id) for trig in triggers: - device_trigger: Trigger = mqtt_data.device_triggers.pop(trig[CONF_DISCOVERY_ID]) - if device_trigger: + trigger_id = f"{device_id}_{trig[CONF_TYPE]}_{trig[CONF_SUBTYPE]}" + if trigger_id in mqtt_data.device_triggers: + device_trigger = mqtt_data.device_triggers.pop(trigger_id) device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data if TYPE_CHECKING: @@ -303,7 +357,7 @@ async def async_get_triggers( if not mqtt_data.device_triggers: return triggers - for discovery_id, trig in mqtt_data.device_triggers.items(): + for trig in mqtt_data.device_triggers.values(): if trig.device_id != device_id or trig.topic is None: continue @@ -312,7 +366,6 @@ async def async_get_triggers( "device_id": device_id, "type": trig.type, "subtype": trig.subtype, - "discovery_id": discovery_id, } triggers.append(trigger) @@ -326,15 +379,33 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" + trigger_id: str | None = None mqtt_data = get_mqtt_data(hass) device_id = config[CONF_DEVICE_ID] - discovery_id = config[CONF_DISCOVERY_ID] - if discovery_id not in mqtt_data.device_triggers: - mqtt_data.device_triggers[discovery_id] = Trigger( + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # In case CONF_DISCOVERY_ID is still used in an automation, + # we reference the device trigger by discovery_id instead of + # referencing it by device_id, type and subtype, which is the default. + discovery_id: str | None = config.get(CONF_DISCOVERY_ID) + if discovery_id is not None: + for trig_id, trig in mqtt_data.device_triggers.items(): + if trig.discovery_id == discovery_id: + trigger_id = trig_id + break + + # Reference the device trigger by device_id, type and subtype. + if trigger_id is None: + trigger_type = config[CONF_TYPE] + trigger_subtype = config[CONF_SUBTYPE] + trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" + + if trigger_id not in mqtt_data.device_triggers: + mqtt_data.device_triggers[trigger_id] = Trigger( hass=hass, device_id=device_id, discovery_data=None, + discovery_id=discovery_id, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=None, @@ -342,6 +413,5 @@ async def async_attach_trigger( qos=None, value_template=None, ) - return await mqtt_data.device_triggers[discovery_id].add_trigger( - action, trigger_info - ) + + return await mqtt_data.device_triggers[trigger_id].add_trigger(action, trigger_info) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3479f1611d8..b1e5c1c18d4 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -220,6 +220,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_supported_color_modes = self._config[CONF_SUPPORTED_COLOR_MODES] if self.supported_color_modes and len(self.supported_color_modes) == 1: self._attr_color_mode = next(iter(self.supported_color_modes)) + else: + self._attr_color_mode = ColorMode.UNKNOWN def _update_color(self, values: dict[str, Any]) -> None: if not self._config[CONF_COLOR_MODE]: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 412664ceedf..4c7837a7a2b 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, + ATTR_SERIAL_NUMBER, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, ATTR_VIA_DEVICE, @@ -27,9 +28,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -83,6 +83,7 @@ from .const import ( CONF_ORIGIN, CONF_QOS, CONF_SCHEMA, + CONF_SERIAL_NUMBER, CONF_SUGGESTED_AREA, CONF_SW_VERSION, CONF_TOPIC, @@ -207,62 +208,6 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) -def validate_sensor_entity_category( - domain: str, discovery: bool -) -> Callable[[ConfigType], ConfigType]: - """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" - - # A guard was added to the core sensor platform with HA core 2023.11.0 - # See: https://github.com/home-assistant/core/pull/101471 - # A developers blog from october 2021 explains the correct uses of the entity category - # See: - # https://developers.home-assistant.io/blog/2021/10/26/config-entity/?_highlight=entity_category#entity-categories - # - # To limitate the impact of the change we use a grace period - # of 3 months for user to update there configs. - - def _validate(config: ConfigType) -> ConfigType: - if ( - CONF_ENTITY_CATEGORY in config - and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG - ): - config_str: str - if not discovery: - config_str = yaml_dump(config) - config.pop(CONF_ENTITY_CATEGORY) - _LOGGER.warning( - "Entity category `config` is invalid for sensors, ignoring. " - "This stops working from HA Core 2024.2.0" - ) - # We only open an issue if the user can fix it - if discovery: - return config - config_file = getattr(config, "__config_file__", "?") - line = getattr(config, "__line__", "?") - hass = async_get_hass() - async_create_issue( - hass, - domain=DOMAIN, - issue_id="invalid_entity_category", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="invalid_entity_category", - learn_more_url=( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ), - translation_placeholders={ - "domain": domain, - "config": config_str, - "config_file": config_file, - "line": line, - }, - ) - return config - - return _validate - - MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), vol.Schema( @@ -277,6 +222,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( vol.Optional(CONF_MODEL): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_HW_VERSION): cv.string, + vol.Optional(CONF_SERIAL_NUMBER): cv.string, vol.Optional(CONF_SW_VERSION): cv.string, vol.Optional(CONF_VIA_DEVICE): cv.string, vol.Optional(CONF_SUGGESTED_AREA): cv.string, @@ -1160,6 +1106,9 @@ def device_info_from_specifications( if CONF_HW_VERSION in specifications: info[ATTR_HW_VERSION] = specifications[CONF_HW_VERSION] + if CONF_SERIAL_NUMBER in specifications: + info[ATTR_SERIAL_NUMBER] = specifications[CONF_SERIAL_NUMBER] + if CONF_SW_VERSION in specifications: info[ATTR_SW_VERSION] = specifications[CONF_SW_VERSION] @@ -1215,7 +1164,6 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str - _issue_key: str | None def __init__( self, @@ -1253,7 +1201,6 @@ class MqttEntity( @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" - self.collect_issues() await super().async_added_to_hass() self._prepare_subscribe_topics() await self._subscribe_topics() @@ -1326,7 +1273,6 @@ class MqttEntity( def _set_entity_name(self, config: ConfigType) -> None: """Help setting the entity name if needed.""" - self._issue_key = None entity_name: str | None | UndefinedType = config.get(CONF_NAME, UNDEFINED) # Only set _attr_name if it is needed if entity_name is not UNDEFINED: @@ -1339,50 +1285,13 @@ class MqttEntity( # don't set the name attribute and derive # the name from the device_class delattr(self, "_attr_name") - if CONF_DEVICE in config: - device_name: str - if CONF_NAME not in config[CONF_DEVICE]: - _LOGGER.info( - "MQTT device information always needs to include a name, got %s, " - "if device information is shared between multiple entities, the device " - "name must be included in each entity's device configuration", - config, - ) - elif (device_name := config[CONF_DEVICE][CONF_NAME]) == entity_name: - self._attr_name = None - if not self._discovery: - self._issue_key = "entity_name_is_device_name_yaml" - _LOGGER.warning( - "MQTT device name is equal to entity name in your config %s, " - "this is not expected. Please correct your configuration. " - "The entity name will be set to `null`", - config, - ) - elif isinstance(entity_name, str) and entity_name.startswith(device_name): - self._attr_name = ( - new_entity_name := entity_name[len(device_name) :].lstrip() - ) - if device_name[:1].isupper(): - # Ensure a capital if the device name first char is a capital - new_entity_name = new_entity_name[:1].upper() + new_entity_name[1:] - if not self._discovery: - self._issue_key = "entity_name_startswith_device_name_yaml" - _LOGGER.warning( - "MQTT entity name starts with the device name in your config %s, " - "this is not expected. Please correct your configuration. " - "The device name prefix will be stripped off the entity name " - "and becomes '%s'", - config, - new_entity_name, - ) - - def collect_issues(self) -> None: - """Process issues for MQTT entities.""" - if self._issue_key is None: - return - mqtt_data = get_mqtt_data(self.hass) - issues = mqtt_data.issues.setdefault(self._issue_key, set()) - issues.add(self.entity_id) + if CONF_DEVICE in config and CONF_NAME not in config[CONF_DEVICE]: + _LOGGER.info( + "MQTT device information always needs to include a name, got %s, " + "if device information is shared between multiple entities, the device " + "name must be included in each entity's device configuration", + config, + ) def _setup_common_attributes_from_config(self, config: ConfigType) -> None: """(Re)Setup the common attributes for the entity.""" diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 63b8d537170..0d009cf356b 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -339,7 +339,6 @@ class MqttData: ) discovery_unsubscribe: list[CALLBACK_TYPE] = field(default_factory=list) integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) - issues: dict[str, set[str]] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) reload_handlers: dict[str, CALLBACK_TYPE] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 2c173f801fa..9d1ed964be3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -44,7 +44,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import ( @@ -88,7 +87,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category(sensor.DOMAIN, discovery=False), _PLATFORM_SCHEMA_BASE, ) @@ -96,7 +94,6 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category(sensor.DOMAIN, discovery=True), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fac2f32d284..ce892e97026 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,29 +1,13 @@ { "issues": { - "deprecation_mqtt_legacy_vacuum_yaml": { - "title": "MQTT vacuum entities with legacy schema found in your configuration.yaml", - "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your configuration.yaml and restart Home Assistant to fix this issue." - }, - "deprecation_mqtt_legacy_vacuum_discovery": { - "title": "MQTT vacuum entities with legacy schema added through MQTT discovery", - "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your devices to use the correct schema and restart Home Assistant to fix this issue." - }, - "entity_name_is_device_name_yaml": { - "title": "Manual configured MQTT entities with a name that is equal to the device name", - "description": "Some MQTT entities have an entity name equal to the device name. This is not expected. The entity name is set to `null` as a work-a-round to avoid a duplicate name. Please update your configuration and restart Home Assistant to fix this issue.\n\nList of affected entities:\n\n{config}" - }, - "entity_name_startswith_device_name_yaml": { - "title": "Manual configured MQTT entities with a name that starts with the device name", - "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped off the entity name as a work-a-round. Please update your configuration and restart Home Assistant to fix this issue. \n\nList of affected entities:\n\n{config}" + "deprecation_mqtt_schema_vacuum_yaml": { + "title": "MQTT vacuum entities with deprecated `schema` config settings found in your configuration.yaml", + "description": "The `schema` setting for MQTT vacuum entities is deprecated and should be removed. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." }, "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." }, - "invalid_entity_category": { - "title": "An MQTT {domain} with an invalid entity category was found", - "description": "Home Assistant detected a manually configured MQTT `{domain}` entity that has an `entity_category` set to `config`. \nConfiguration file: **{config_file}**\nNear line: **{line}**\n\nConfig with invalid setting:\n\n```yaml\n{config}\n```\n\nWhen set, make sure `entity_category` for a `{domain}` is set to `diagnostic` or `None`. Update your YAML configuration and restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum.py similarity index 65% rename from homeassistant/components/mqtt/vacuum/schema_state.py rename to homeassistant/components/mqtt/vacuum.py index a51429f0c05..96c0871e27b 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -1,10 +1,19 @@ -"""Support for a State MQTT vacuum.""" +"""Support for MQTT vacuums.""" + +# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +# and was removed with HA Core 2024.2.0 +# The use of the schema attribute with MQTT vacuum was deprecated with HA Core 2024.2 +# the attribute will be remove with HA Core 2024.8 + from __future__ import annotations +from collections.abc import Callable +import logging from typing import Any, cast import voluptuous as vol +from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, STATE_CLEANING, @@ -21,58 +30,37 @@ from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, async_get_hass, callback 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.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads_object -from .. import subscription -from ..config import MQTT_BASE_SCHEMA -from ..const import ( +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN, + CONF_SCHEMA, CONF_STATE_TOPIC, + DOMAIN, ) -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change -from ..models import ReceiveMessage -from ..util import valid_publish_topic -from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services - -SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { - VacuumEntityFeature.START: "start", - VacuumEntityFeature.PAUSE: "pause", - VacuumEntityFeature.STOP: "stop", - VacuumEntityFeature.RETURN_HOME: "return_home", - VacuumEntityFeature.FAN_SPEED: "fan_speed", - VacuumEntityFeature.BATTERY: "battery", - VacuumEntityFeature.STATUS: "status", - VacuumEntityFeature.SEND_COMMAND: "send_command", - VacuumEntityFeature.LOCATE: "locate", - VacuumEntityFeature.CLEAN_SPOT: "clean_spot", -} - -STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} - - -DEFAULT_SERVICES = ( - VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT -) -ALL_SERVICES = ( - DEFAULT_SERVICES - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.SEND_COMMAND +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 ReceiveMessage +from .util import valid_publish_topic + +LEGACY = "legacy" +STATE = "state" BATTERY = "battery_level" FAN_SPEED = "fan_speed" @@ -102,7 +90,7 @@ CONF_SEND_COMMAND_TOPIC = "send_command_topic" DEFAULT_NAME = "MQTT State Vacuum" DEFAULT_RETAIN = False -DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) + DEFAULT_PAYLOAD_RETURN_TO_BASE = "return_to_base" DEFAULT_PAYLOAD_STOP = "stop" DEFAULT_PAYLOAD_CLEAN_SPOT = "clean_spot" @@ -110,6 +98,52 @@ DEFAULT_PAYLOAD_LOCATE = "locate" DEFAULT_PAYLOAD_START = "start" DEFAULT_PAYLOAD_PAUSE = "pause" +_LOGGER = logging.getLogger(__name__) + +SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { + VacuumEntityFeature.START: "start", + VacuumEntityFeature.PAUSE: "pause", + VacuumEntityFeature.STOP: "stop", + VacuumEntityFeature.RETURN_HOME: "return_home", + VacuumEntityFeature.FAN_SPEED: "fan_speed", + VacuumEntityFeature.BATTERY: "battery", + VacuumEntityFeature.STATUS: "status", + VacuumEntityFeature.SEND_COMMAND: "send_command", + VacuumEntityFeature.LOCATE: "locate", + VacuumEntityFeature.CLEAN_SPOT: "clean_spot", +} + +STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} +DEFAULT_SERVICES = ( + VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.CLEAN_SPOT +) +ALL_SERVICES = ( + DEFAULT_SERVICES + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.SEND_COMMAND +) + + +def services_to_strings( + services: VacuumEntityFeature, + service_to_string: dict[VacuumEntityFeature, str], +) -> list[str]: + """Convert SUPPORT_* service bitmask to list of service strings.""" + return [ + service_to_string[service] + for service in service_to_string + if service & services + ] + + +DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) + _FEATURE_PAYLOADS = { VacuumEntityFeature.START: CONF_PAYLOAD_START, VacuumEntityFeature.STOP: CONF_PAYLOAD_STOP, @@ -119,40 +153,105 @@ _FEATURE_PAYLOADS = { VacuumEntityFeature.RETURN_HOME: CONF_PAYLOAD_RETURN_TO_BASE, } -PLATFORM_SCHEMA_STATE_MODERN = ( - MQTT_BASE_SCHEMA.extend( - { - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_NAME): vol.Any(cv.string, None), - vol.Optional( - CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT - ): cv.string, - vol.Optional( - CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE - ): cv.string, - vol.Optional(CONF_PAYLOAD_START, default=DEFAULT_PAYLOAD_START): cv.string, - vol.Optional(CONF_PAYLOAD_PAUSE, default=DEFAULT_PAYLOAD_PAUSE): cv.string, - vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, - vol.Optional(CONF_STATE_TOPIC): valid_publish_topic, - vol.Optional( - CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS - ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - } - ) - .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) - .extend(MQTT_VACUUM_SCHEMA.schema) +MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( + { + vacuum.ATTR_BATTERY_ICON, + vacuum.ATTR_BATTERY_LEVEL, + vacuum.ATTR_FAN_SPEED, + } ) -DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) +MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" + + +def _fail_legacy_config(discovery: bool) -> Callable[[ConfigType], ConfigType]: + @callback + def _fail_legacy_config_callback(config: ConfigType) -> ConfigType: + """Fail the legacy schema.""" + if CONF_SCHEMA not in config: + return config + + if config[CONF_SCHEMA] == "legacy": + raise vol.Invalid( + "The support for the `legacy` MQTT vacuum schema has been removed" + ) + + if discovery: + return config + + translation_key = "deprecation_mqtt_schema_vacuum_yaml" + hass = async_get_hass() + async_create_issue( + hass, + DOMAIN, + translation_key, + breaks_in_ha_version="2024.8.0", + is_fixable=False, + translation_key=translation_key, + learn_more_url=MQTT_VACUUM_DOCS_URL, + severity=IssueSeverity.WARNING, + ) + return config + + return _fail_legacy_config_callback + + +VACUUM_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional( + CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT + ): cv.string, + vol.Optional(CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE): cv.string, + vol.Optional( + CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE + ): cv.string, + vol.Optional(CONF_PAYLOAD_START, default=DEFAULT_PAYLOAD_START): cv.string, + vol.Optional(CONF_PAYLOAD_PAUSE, default=DEFAULT_PAYLOAD_PAUSE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, + vol.Optional(CONF_STATE_TOPIC): valid_publish_topic, + vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS): vol.All( + cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())] + ), + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_SCHEMA): vol.All(vol.Lower, vol.Any(LEGACY, STATE)), + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = vol.All( + _fail_legacy_config(discovery=True), + VACUUM_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), + cv.deprecated(CONF_SCHEMA), +) + +PLATFORM_SCHEMA_MODERN = vol.All( + _fail_legacy_config(discovery=False), + VACUUM_BASE_SCHEMA, + cv.deprecated(CONF_SCHEMA), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT vacuum through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttStateVacuum, + vacuum.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) class MqttStateVacuum(MqttEntity, StateVacuumEntity): @@ -182,12 +281,22 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" - return DISCOVERY_SCHEMA_STATE + return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + + def _strings_to_services( + strings: list[str], string_to_service: dict[str, VacuumEntityFeature] + ) -> VacuumEntityFeature: + """Convert service strings to SUPPORT_* service bitmask.""" + services = VacuumEntityFeature.STATE + for string in strings: + services |= string_to_service[string] + return services + supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = VacuumEntityFeature.STATE | strings_to_services( + self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py deleted file mode 100644 index fabbb9868df..00000000000 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Support for MQTT vacuums.""" - -# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 - -from __future__ import annotations - -import logging - -import voluptuous as vol - -from homeassistant.components import vacuum -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, async_get_hass, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType - -from ..const import DOMAIN -from ..mixins import async_setup_entity_entry_helper -from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE -from .schema_legacy import ( - DISCOVERY_SCHEMA_LEGACY, - PLATFORM_SCHEMA_LEGACY_MODERN, - MqttVacuum, -) -from .schema_state import ( - DISCOVERY_SCHEMA_STATE, - PLATFORM_SCHEMA_STATE_MODERN, - MqttStateVacuum, -) - -_LOGGER = logging.getLogger(__name__) - -MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" - - -# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 -def warn_for_deprecation_legacy_schema( - hass: HomeAssistant, config: ConfigType, discovery: bool -) -> None: - """Warn for deprecation of legacy schema.""" - if config[CONF_SCHEMA] == STATE: - return - - key_suffix = "discovery" if discovery else "yaml" - translation_key = f"deprecation_mqtt_legacy_vacuum_{key_suffix}" - async_create_issue( - hass, - DOMAIN, - translation_key, - breaks_in_ha_version="2024.2.0", - is_fixable=False, - translation_key=translation_key, - learn_more_url=MQTT_VACUUM_DOCS_URL, - severity=IssueSeverity.WARNING, - ) - _LOGGER.warning( - "Deprecated `legacy` schema detected for MQTT vacuum, expected `state` schema, config found: %s", - config, - ) - - -@callback -def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: - """Validate MQTT vacuum schema.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - - schemas = {LEGACY: DISCOVERY_SCHEMA_LEGACY, STATE: DISCOVERY_SCHEMA_STATE} - config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) - hass = async_get_hass() - warn_for_deprecation_legacy_schema(hass, config, True) - return config - - -@callback -def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: - """Validate MQTT vacuum modern schema.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - - schemas = { - LEGACY: PLATFORM_SCHEMA_LEGACY_MODERN, - STATE: PLATFORM_SCHEMA_STATE_MODERN, - } - config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - hass = async_get_hass() - warn_for_deprecation_legacy_schema(hass, config, False) - return config - - -DISCOVERY_SCHEMA = vol.All( - MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery -) - -PLATFORM_SCHEMA_MODERN = vol.All( - MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up MQTT vacuum through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( - hass, - config_entry, - None, - vacuum.DOMAIN, - async_add_entities, - DISCOVERY_SCHEMA, - PLATFORM_SCHEMA_MODERN, - {"legacy": MqttVacuum, "state": MqttStateVacuum}, - ) diff --git a/homeassistant/components/mqtt/vacuum/const.py b/homeassistant/components/mqtt/vacuum/const.py deleted file mode 100644 index 26e11125556..00000000000 --- a/homeassistant/components/mqtt/vacuum/const.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Shared constants.""" -from homeassistant.components import vacuum - -MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( - { - vacuum.ATTR_BATTERY_ICON, - vacuum.ATTR_BATTERY_LEVEL, - vacuum.ATTR_FAN_SPEED, - } -) diff --git a/homeassistant/components/mqtt/vacuum/schema.py b/homeassistant/components/mqtt/vacuum/schema.py deleted file mode 100644 index 78175f61255..00000000000 --- a/homeassistant/components/mqtt/vacuum/schema.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared schema code.""" -from __future__ import annotations - -import voluptuous as vol - -from homeassistant.components.vacuum import VacuumEntityFeature - -from ..const import CONF_SCHEMA - -LEGACY = "legacy" -STATE = "state" - -MQTT_VACUUM_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SCHEMA, default=LEGACY): vol.All( - vol.Lower, vol.Any(LEGACY, STATE) - ) - } -) - - -def services_to_strings( - services: VacuumEntityFeature, - service_to_string: dict[VacuumEntityFeature, str], -) -> list[str]: - """Convert SUPPORT_* service bitmask to list of service strings.""" - return [ - service_to_string[service] - for service in service_to_string - if service & services - ] - - -def strings_to_services( - strings: list[str], string_to_service: dict[str, VacuumEntityFeature] -) -> VacuumEntityFeature: - """Convert service strings to SUPPORT_* service bitmask.""" - services = VacuumEntityFeature(0) - for string in strings: - services |= string_to_service[string] - return services diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py deleted file mode 100644 index ab13de59ede..00000000000 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Support for Legacy MQTT vacuum. - -The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -and is will be removed with HA Core 2024.2.0 -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -import voluptuous as vol - -from homeassistant.components.vacuum import ( - ATTR_STATUS, - ENTITY_ID_FORMAT, - VacuumEntity, - VacuumEntityFeature, -) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.typing import ConfigType - -from .. import subscription -from ..config import MQTT_BASE_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change -from ..models import ( - MqttValueTemplate, - PayloadSentinel, - ReceiveMessage, - ReceivePayloadType, -) -from ..util import valid_publish_topic -from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services - -SERVICE_TO_STRING = { - VacuumEntityFeature.TURN_ON: "turn_on", - VacuumEntityFeature.TURN_OFF: "turn_off", - VacuumEntityFeature.PAUSE: "pause", - VacuumEntityFeature.STOP: "stop", - VacuumEntityFeature.RETURN_HOME: "return_home", - VacuumEntityFeature.FAN_SPEED: "fan_speed", - VacuumEntityFeature.BATTERY: "battery", - VacuumEntityFeature.STATUS: "status", - VacuumEntityFeature.SEND_COMMAND: "send_command", - VacuumEntityFeature.LOCATE: "locate", - VacuumEntityFeature.CLEAN_SPOT: "clean_spot", -} - -STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} - -DEFAULT_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT -) -ALL_SERVICES = ( - DEFAULT_SERVICES - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.SEND_COMMAND -) - -CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES -CONF_BATTERY_LEVEL_TEMPLATE = "battery_level_template" -CONF_BATTERY_LEVEL_TOPIC = "battery_level_topic" -CONF_CHARGING_TEMPLATE = "charging_template" -CONF_CHARGING_TOPIC = "charging_topic" -CONF_CLEANING_TEMPLATE = "cleaning_template" -CONF_CLEANING_TOPIC = "cleaning_topic" -CONF_DOCKED_TEMPLATE = "docked_template" -CONF_DOCKED_TOPIC = "docked_topic" -CONF_ERROR_TEMPLATE = "error_template" -CONF_ERROR_TOPIC = "error_topic" -CONF_FAN_SPEED_LIST = "fan_speed_list" -CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" -CONF_FAN_SPEED_TOPIC = "fan_speed_topic" -CONF_PAYLOAD_CLEAN_SPOT = "payload_clean_spot" -CONF_PAYLOAD_LOCATE = "payload_locate" -CONF_PAYLOAD_RETURN_TO_BASE = "payload_return_to_base" -CONF_PAYLOAD_START_PAUSE = "payload_start_pause" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_PAYLOAD_TURN_OFF = "payload_turn_off" -CONF_PAYLOAD_TURN_ON = "payload_turn_on" -CONF_SEND_COMMAND_TOPIC = "send_command_topic" -CONF_SET_FAN_SPEED_TOPIC = "set_fan_speed_topic" - -DEFAULT_NAME = "MQTT Vacuum" -DEFAULT_PAYLOAD_CLEAN_SPOT = "clean_spot" -DEFAULT_PAYLOAD_LOCATE = "locate" -DEFAULT_PAYLOAD_RETURN_TO_BASE = "return_to_base" -DEFAULT_PAYLOAD_START_PAUSE = "start_pause" -DEFAULT_PAYLOAD_STOP = "stop" -DEFAULT_PAYLOAD_TURN_OFF = "turn_off" -DEFAULT_PAYLOAD_TURN_ON = "turn_on" -DEFAULT_RETAIN = False -DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) - -MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED = MQTT_VACUUM_ATTRIBUTES_BLOCKED | frozenset( - {ATTR_STATUS} -) - -PLATFORM_SCHEMA_LEGACY_MODERN = ( - MQTT_BASE_SCHEMA.extend( - { - vol.Inclusive(CONF_BATTERY_LEVEL_TEMPLATE, "battery"): cv.template, - vol.Inclusive(CONF_BATTERY_LEVEL_TOPIC, "battery"): valid_publish_topic, - vol.Inclusive(CONF_CHARGING_TEMPLATE, "charging"): cv.template, - vol.Inclusive(CONF_CHARGING_TOPIC, "charging"): valid_publish_topic, - vol.Inclusive(CONF_CLEANING_TEMPLATE, "cleaning"): cv.template, - vol.Inclusive(CONF_CLEANING_TOPIC, "cleaning"): valid_publish_topic, - vol.Inclusive(CONF_DOCKED_TEMPLATE, "docked"): cv.template, - vol.Inclusive(CONF_DOCKED_TOPIC, "docked"): valid_publish_topic, - vol.Inclusive(CONF_ERROR_TEMPLATE, "error"): cv.template, - vol.Inclusive(CONF_ERROR_TOPIC, "error"): valid_publish_topic, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Inclusive(CONF_FAN_SPEED_TEMPLATE, "fan_speed"): cv.template, - vol.Inclusive(CONF_FAN_SPEED_TOPIC, "fan_speed"): valid_publish_topic, - vol.Optional(CONF_NAME): vol.Any(cv.string, None), - vol.Optional( - CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT - ): cv.string, - vol.Optional( - CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_START_PAUSE, default=DEFAULT_PAYLOAD_START_PAUSE - ): cv.string, - vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional( - CONF_PAYLOAD_TURN_OFF, default=DEFAULT_PAYLOAD_TURN_OFF - ): cv.string, - vol.Optional( - CONF_PAYLOAD_TURN_ON, default=DEFAULT_PAYLOAD_TURN_ON - ): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, - vol.Optional( - CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS - ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - } - ) - .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) - .extend(MQTT_VACUUM_SCHEMA.schema) -) - -DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend( - {}, extra=vol.REMOVE_EXTRA -) - - -_COMMANDS = { - VacuumEntityFeature.TURN_ON: { - "payload": CONF_PAYLOAD_TURN_ON, - "status": "Cleaning", - }, - VacuumEntityFeature.TURN_OFF: { - "payload": CONF_PAYLOAD_TURN_OFF, - "status": "Turning Off", - }, - VacuumEntityFeature.STOP: { - "payload": CONF_PAYLOAD_STOP, - "status": "Stopping the current task", - }, - VacuumEntityFeature.CLEAN_SPOT: { - "payload": CONF_PAYLOAD_CLEAN_SPOT, - "status": "Cleaning spot", - }, - VacuumEntityFeature.LOCATE: { - "payload": CONF_PAYLOAD_LOCATE, - "status": "Hi, I'm over here!", - }, - VacuumEntityFeature.PAUSE: { - "payload": CONF_PAYLOAD_START_PAUSE, - "status": "Pausing/Resuming cleaning...", - }, - VacuumEntityFeature.RETURN_HOME: { - "payload": CONF_PAYLOAD_RETURN_TO_BASE, - "status": "Returning home...", - }, -} - - -class MqttVacuum(MqttEntity, VacuumEntity): - """Representation of a MQTT-controlled legacy vacuum.""" - - _attr_battery_level = 0 - _attr_is_on = False - _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED - _charging: bool = False - _cleaning: bool = False - _command_topic: str | None - _docked: bool = False - _default_name = DEFAULT_NAME - _entity_id_format = ENTITY_ID_FORMAT - _encoding: str | None - _error: str | None = None - _qos: bool - _retain: bool - _payloads: dict[str, str] - _send_command_topic: str | None - _set_fan_speed_topic: str | None - _state_topics: dict[str, str | None] - _templates: dict[ - str, Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - ] - - @staticmethod - def config_schema() -> vol.Schema: - """Return the config schema.""" - return DISCOVERY_SCHEMA_LEGACY - - def _setup_from_config(self, config: ConfigType) -> None: - """(Re)Setup the entity.""" - supported_feature_strings = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = strings_to_services( - supported_feature_strings, STRING_TO_SERVICE - ) - self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] - self._qos = config[CONF_QOS] - self._retain = config[CONF_RETAIN] - self._encoding = config[CONF_ENCODING] or None - - self._command_topic = config.get(CONF_COMMAND_TOPIC) - self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) - self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) - - self._payloads = { - key: config[key] - for key in ( - CONF_PAYLOAD_TURN_ON, - CONF_PAYLOAD_TURN_OFF, - CONF_PAYLOAD_RETURN_TO_BASE, - CONF_PAYLOAD_STOP, - CONF_PAYLOAD_CLEAN_SPOT, - CONF_PAYLOAD_LOCATE, - CONF_PAYLOAD_START_PAUSE, - ) - } - self._state_topics = { - key: config.get(key) - for key in ( - CONF_BATTERY_LEVEL_TOPIC, - CONF_CHARGING_TOPIC, - CONF_CLEANING_TOPIC, - CONF_DOCKED_TOPIC, - CONF_ERROR_TOPIC, - CONF_FAN_SPEED_TOPIC, - ) - } - self._templates = { - key: MqttValueTemplate( - config[key], entity=self - ).async_render_with_possible_json_value - for key in ( - CONF_BATTERY_LEVEL_TEMPLATE, - CONF_CHARGING_TEMPLATE, - CONF_CLEANING_TEMPLATE, - CONF_DOCKED_TEMPLATE, - CONF_ERROR_TEMPLATE, - CONF_FAN_SPEED_TEMPLATE, - ) - if key in config - } - - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, - { - "_attr_battery_level", - "_attr_fan_speed", - "_attr_is_on", - # We track _attr_status and _charging as they are used to - # To determine the batery_icon. - # We do not need to track _docked as it is - # not leading to entity changes directly. - "_attr_status", - "_charging", - }, - ) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT message.""" - if ( - msg.topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] - and CONF_BATTERY_LEVEL_TEMPLATE in self._config - ): - battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if battery_level and battery_level is not PayloadSentinel.DEFAULT: - self._attr_battery_level = max(0, min(100, int(battery_level))) - - if ( - msg.topic == self._state_topics[CONF_CHARGING_TOPIC] - and CONF_CHARGING_TEMPLATE in self._templates - ): - charging = self._templates[CONF_CHARGING_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if charging and charging is not PayloadSentinel.DEFAULT: - self._charging = cv.boolean(charging) - - if ( - msg.topic == self._state_topics[CONF_CLEANING_TOPIC] - and CONF_CLEANING_TEMPLATE in self._config - ): - cleaning = self._templates[CONF_CLEANING_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if cleaning and cleaning is not PayloadSentinel.DEFAULT: - self._attr_is_on = cv.boolean(cleaning) - - if ( - msg.topic == self._state_topics[CONF_DOCKED_TOPIC] - and CONF_DOCKED_TEMPLATE in self._config - ): - docked = self._templates[CONF_DOCKED_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if docked and docked is not PayloadSentinel.DEFAULT: - self._docked = cv.boolean(docked) - - if ( - msg.topic == self._state_topics[CONF_ERROR_TOPIC] - and CONF_ERROR_TEMPLATE in self._config - ): - error = self._templates[CONF_ERROR_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if error is not PayloadSentinel.DEFAULT: - self._error = cv.string(error) - - if self._docked: - if self._charging: - self._attr_status = "Docked & Charging" - else: - self._attr_status = "Docked" - elif self.is_on: - self._attr_status = "Cleaning" - elif self._error: - self._attr_status = f"Error: {self._error}" - else: - self._attr_status = "Stopped" - - if ( - msg.topic == self._state_topics[CONF_FAN_SPEED_TOPIC] - and CONF_FAN_SPEED_TEMPLATE in self._config - ): - fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if fan_speed and fan_speed is not PayloadSentinel.DEFAULT: - self._attr_fan_speed = str(fan_speed) - - topics_list = {topic for topic in self._state_topics.values() if topic} - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - f"topic{i}": { - "topic": topic, - "msg_callback": message_received, - "qos": self._qos, - "encoding": self._encoding, - } - for i, topic in enumerate(topics_list) - }, - ) - - async def _subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner. - - No need to check VacuumEntityFeature.BATTERY, this won't be called if - battery_level is None. - """ - return icon_for_battery_level( - battery_level=self.battery_level, charging=self._charging - ) - - async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: - """Publish a command.""" - - if self._command_topic is None: - return - - await self.async_publish( - self._command_topic, - self._payloads[_COMMANDS[feature]["payload"]], - qos=self._qos, - retain=self._retain, - encoding=self._encoding, - ) - self._attr_status = _COMMANDS[feature]["status"] - self.async_write_ha_state() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on.""" - await self._async_publish_command(VacuumEntityFeature.TURN_ON) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off.""" - await self._async_publish_command(VacuumEntityFeature.TURN_OFF) - - async def async_stop(self, **kwargs: Any) -> None: - """Stop the vacuum.""" - await self._async_publish_command(VacuumEntityFeature.STOP) - - async def async_clean_spot(self, **kwargs: Any) -> None: - """Perform a spot clean-up.""" - await self._async_publish_command(VacuumEntityFeature.CLEAN_SPOT) - - async def async_locate(self, **kwargs: Any) -> None: - """Locate the vacuum (usually by playing a song).""" - await self._async_publish_command(VacuumEntityFeature.LOCATE) - - async def async_start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - await self._async_publish_command(VacuumEntityFeature.PAUSE) - - async def async_return_to_base(self, **kwargs: Any) -> None: - """Tell the vacuum to return to its dock.""" - await self._async_publish_command(VacuumEntityFeature.RETURN_HOME) - - async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: - """Set fan speed.""" - if ( - self._set_fan_speed_topic is None - or (self.supported_features & VacuumEntityFeature.FAN_SPEED == 0) - or fan_speed not in self.fan_speed_list - ): - return None - - await self.async_publish( - self._set_fan_speed_topic, - fan_speed, - self._qos, - self._retain, - self._encoding, - ) - self._attr_status = f"Setting fan to {fan_speed}..." - self.async_write_ha_state() - - async def async_send_command( - self, - command: str, - params: dict[str, Any] | list[Any] | None = None, - **kwargs: Any, - ) -> None: - """Send a command to a vacuum cleaner.""" - if ( - self._send_command_topic is None - or self.supported_features & VacuumEntityFeature.SEND_COMMAND == 0 - ): - return - if params: - message: dict[str, Any] = {"command": command} - message.update(params) - message_payload = json_dumps(message) - else: - message_payload = command - await self.async_publish( - self._send_command_topic, - message_payload, - self._qos, - self._retain, - self._encoding, - ) - self._attr_status = f"Sending command {message_payload}..." - self.async_write_ha_state() diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 2ccf754bbbd..264bbe15520 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -17,7 +18,7 @@ from .const import DOMAIN BINARY_SENSORS = ( BinarySensorEntityDescription( key="mullvad_exit_ip", - name="Mullvad Exit IP", + translation_key="exit_ip", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) @@ -40,6 +41,8 @@ async def async_setup_entry( class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): """Represents a Mullvad binary sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator, @@ -50,6 +53,11 @@ class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): super().__init__(coordinator) self.entity_description = entity_description self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name="Mullvad VPN", + manufacturer="Mullvad", + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/mullvad/strings.json b/homeassistant/components/mullvad/strings.json index 7910a40ec35..3e029184155 100644 --- a/homeassistant/components/mullvad/strings.json +++ b/homeassistant/components/mullvad/strings.json @@ -12,5 +12,12 @@ "description": "Set up the Mullvad VPN integration?" } } + }, + "entity": { + "binary_sensor": { + "exit_ip": { + "name": "Exit IP" + } + } } } diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d532135304a..0058fca021e 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -70,11 +70,12 @@ class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST + _enable_turn_on_off_backwards_compatibility = False @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON set_req = self.gateway.const.SetReq if set_req.V_HVAC_SPEED in self._values: features = features | ClimateEntityFeature.FAN_MODE diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py new file mode 100644 index 00000000000..15ae1eb75c2 --- /dev/null +++ b/homeassistant/components/myuplink/__init__.py @@ -0,0 +1,71 @@ +"""The myUplink integration.""" +from __future__ import annotations + +from myuplink.api import MyUplinkAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up myUplink from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation) + auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + + # Setup MyUplinkAPI and coordinator for data fetch + api = MyUplinkAPI(auth) + coordinator = MyUplinkDataCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + # Update device registry + create_devices(hass, config_entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(config_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 + + +@callback +def create_devices( + hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator +) -> None: + """Update all devices.""" + device_registry = dr.async_get(hass) + + for device_id, device in coordinator.data.devices.items(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, device_id)}, + name=device.productName, + manufacturer=device.productName.split(" ")[0], + model=device.productName, + sw_version=device.firmwareCurrent, + ) diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py new file mode 100644 index 00000000000..5d0fcaf521a --- /dev/null +++ b/homeassistant/components/myuplink/api.py @@ -0,0 +1,31 @@ +"""API for myUplink bound to Home Assistant OAuth.""" +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientSession +from myuplink.auth_abstract import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import API_ENDPOINT + + +class AsyncConfigEntryAuth(AbstractAuth): # type: ignore[misc] + """Provide myUplink authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize myUplink auth.""" + super().__init__(websession, API_ENDPOINT) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/myuplink/application_credentials.py b/homeassistant/components/myuplink/application_credentials.py new file mode 100644 index 00000000000..fe3cd22f037 --- /dev/null +++ b/homeassistant/components/myuplink/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the myUplink integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py new file mode 100644 index 00000000000..e8377f2682b --- /dev/null +++ b/homeassistant/components/myuplink/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for myUplink.""" +import logging +from typing import Any + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle myUplink OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH2_SCOPES)} diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py new file mode 100644 index 00000000000..9adb1eb0e30 --- /dev/null +++ b/homeassistant/components/myuplink/const.py @@ -0,0 +1,8 @@ +"""Constants for the myUplink integration.""" + +DOMAIN = "myuplink" + +API_ENDPOINT = "https://api.myuplink.com" +OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" +OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" +OAUTH2_SCOPES = ["READSYSTEM", "offline_access"] diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py new file mode 100644 index 00000000000..4cd66adab2b --- /dev/null +++ b/homeassistant/components/myuplink/coordinator.py @@ -0,0 +1,65 @@ +"""Coordinator for myUplink.""" +import asyncio.timeouts +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from myuplink.api import MyUplinkAPI +from myuplink.models import Device, DevicePoint, System + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class CoordinatorData: + """Represent coordinator data.""" + + systems: list[System] + devices: dict[str, Device] + points: dict[str, dict[str, DevicePoint]] + time: datetime + + +class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Coordinator for myUplink data.""" + + def __init__(self, hass: HomeAssistant, api: MyUplinkAPI) -> None: + """Initialize myUplink coordinator.""" + super().__init__( + hass, + _LOGGER, + name="myuplink", + update_interval=timedelta(seconds=60), + ) + self.api = api + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from the myUplink API.""" + async with asyncio.timeout(10): + # Get systems + systems = await self.api.async_get_systems() + + devices: dict[str, Device] = {} + points: dict[str, dict[str, DevicePoint]] = {} + device_ids = [ + device.deviceId for system in systems for device in system.devices + ] + for device_id in device_ids: + # Get device info + api_device_info = await self.api.async_get_device(device_id) + devices[device_id] = api_device_info + + # Get device points (data) + api_device_points = await self.api.async_get_device_points(device_id) + point_info: dict[str, DevicePoint] = {} + for point in api_device_points: + point_info[point.parameter_id] = point + + points[device_id] = point_info + + return CoordinatorData( + systems=systems, devices=devices, points=points, time=datetime.now() + ) diff --git a/homeassistant/components/myuplink/entity.py b/homeassistant/components/myuplink/entity.py new file mode 100644 index 00000000000..e3d6184c368 --- /dev/null +++ b/homeassistant/components/myuplink/entity.py @@ -0,0 +1,28 @@ +"""Provide a common entity class for myUplink entities.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + + +class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): + """Representation of a sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + # Internal properties + self.device_id = device_id + + # Basic values + self._attr_unique_id = f"{device_id}-{unique_id_suffix}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json new file mode 100644 index 00000000000..303af547335 --- /dev/null +++ b/homeassistant/components/myuplink/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "myuplink", + "name": "myUplink", + "codeowners": ["@pajzo"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/myuplink", + "iot_class": "cloud_polling", + "requirements": ["myuplink==0.0.9"] +} diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py new file mode 100644 index 00000000000..5b08b26a306 --- /dev/null +++ b/homeassistant/components/myuplink/sensor.py @@ -0,0 +1,89 @@ +"""Sensor for myUplink.""" + +from myuplink.models import DevicePoint + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity + +DEVICE_POINT_DESCRIPTIONS = { + "°C": SensorEntityDescription( + key="celsius", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink sensor.""" + entities: list[SensorEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point sensors + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + entities.append( + MyUplinkDevicePointSensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=DEVICE_POINT_DESCRIPTIONS.get( + device_point.parameter_unit + ), + unique_id_suffix=point_id, + ) + ) + + async_add_entities(entities) + + +class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): + """Representation of a myUplink device point sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name.replace("\u002d", "") + + if entity_description is not None: + self.entity_description = entity_description + else: + self._attr_native_unit_of_measurement = device_point.parameter_unit + + @property + def native_value(self) -> StateType: + """Sensor state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return device_point.value # type: ignore[no-any-return] diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json new file mode 100644 index 00000000000..569e148a5a3 --- /dev/null +++ b/homeassistant/components/myuplink/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "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%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d5881f52d8d..28f9c282a73 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -90,7 +90,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): +class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Nettigo Air Monitor data.""" def __init__( diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 52bc841f3b5..b172d84533c 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -4,94 +4,31 @@ import logging import aiohttp from pybotvac import Account from pybotvac.exceptions import NeatoException -import voluptuous as vol -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import NEATO_CONFIG, NEATO_DOMAIN, NEATO_LOGIN +from .const import NEATO_DOMAIN, NEATO_LOGIN from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(NEATO_DOMAIN), - { - NEATO_DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ - Platform.CAMERA, - Platform.VACUUM, - Platform.SWITCH, - Platform.SENSOR, Platform.BUTTON, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Neato component.""" - hass.data[NEATO_DOMAIN] = {} - - if NEATO_DOMAIN not in config: - return True - - hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN] - await async_import_client_credential( - hass, - NEATO_DOMAIN, - ClientCredential( - config[NEATO_DOMAIN][CONF_CLIENT_ID], - config[NEATO_DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Neato integration in YAML is deprecated and " - "will be removed in a future release; Your existing OAuth " - "Application Credentials have been imported into the UI " - "automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{NEATO_DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=NEATO_DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": NEATO_DOMAIN, - "integration_title": "Neato Botvac", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" + hass.data.setdefault(NEATO_DOMAIN, {}) if CONF_TOKEN not in entry.data: raise ConfigEntryAuthFailed diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py index 0cd8fb932ce..4ec894179ea 100644 --- a/homeassistant/components/neato/const.py +++ b/homeassistant/components/neato/const.py @@ -3,7 +3,6 @@ NEATO_DOMAIN = "neato" CONF_VENDOR = "vendor" -NEATO_CONFIG = "neato_config" NEATO_LOGIN = "neato_login" NEATO_MAP_DATA = "neato_map_data" NEATO_PERSISTENT_MAPS = "neato_persistent_maps" diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index e85073061c2..8d1d58f9117 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -95,7 +95,7 @@ CONFIG_SCHEMA = vol.Schema( ) # Platforms for SDM API -PLATFORMS = [Platform.SENSOR, Platform.CAMERA, Platform.CLIMATE] +PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.SENSOR] # Fetch media events with a disk backed cache, with a limit for each camera # device. The largest media items are mp4 clips at ~120kb each, and we target diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 03fb79eb78e..2d0186b2bfd 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -100,6 +100,7 @@ class ThermostatEntity(ClimateEntity): _attr_has_entity_name = True _attr_should_poll = False _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" @@ -246,7 +247,7 @@ class ThermostatEntity(ClimateEntity): def _get_supported_features(self) -> ClimateEntityFeature: """Compute the bitmap of supported features from the current state.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON if HVACMode.HEAT_COOL in self.hvac_modes: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE if HVACMode.HEAT in self.hvac_modes or HVACMode.COOL in self.hvac_modes: diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 4535805915b..c514e7b890d 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,26 +8,16 @@ from typing import Any import aiohttp import pyatmo -import voluptuous as vol from homeassistant.components import cloud -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.webhook import ( async_generate_url as webhook_generate_url, async_register as webhook_register, async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -36,7 +26,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType @@ -61,20 +50,7 @@ from .webhook import async_handle_webhook _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) MAX_WEBHOOK_RETRIES = 3 @@ -90,39 +66,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_CAMERAS: {}, } - if DOMAIN not in config: - return True - - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Netatmo integration in YAML is deprecated and " - "will be removed in a future release; Your existing configuration " - "(including OAuth Application Credentials) have been imported into " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Netatmo", - }, - ) - return True diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 7fab99a6f39..dc566afd233 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -39,7 +39,7 @@ from .const import ( WEBHOOK_PUSH_TYPE, ) from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -79,7 +79,7 @@ async def async_setup_entry( ) -class NetatmoCamera(NetatmoBase, Camera): +class NetatmoCamera(NetatmoBaseEntity, Camera): """Representation of a Netatmo camera.""" _attr_brand = MANUFACTURER diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 5a05818d3f2..db12efb2f01 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -56,7 +56,7 @@ from .const import ( SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,10 @@ PRESET_SCHEDULE = "Schedule" PRESET_MANUAL = "Manual" SUPPORT_FLAGS = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] @@ -178,7 +181,7 @@ async def async_setup_entry( ) -class NetatmoThermostat(NetatmoBase, ClimateEntity): +class NetatmoThermostat(NetatmoBaseEntity, ClimateEntity): """Representation a Netatmo thermostat.""" _attr_hvac_mode = HVACMode.AUTO @@ -187,6 +190,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): _attr_supported_features = SUPPORT_FLAGS _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, netatmo_device: NetatmoRoom) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 3fe456dd657..416c5668eae 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -42,6 +42,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" NETATMO_CREATE_SELECT = "netatmo_create_select" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 2e4bf9e7d3c..b9537fee179 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ async def async_setup_entry( ) -class NetatmoCover(NetatmoBase, CoverEntity): +class NetatmoCover(NetatmoBaseEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index e1d100f773e..bfc77a09548 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -17,6 +17,7 @@ from pyatmo.modules.device_types import ( DeviceType as NetatmoDeviceType, ) +from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import ( @@ -36,6 +37,7 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, NETATMO_CREATE_COVER, + NETATMO_CREATE_FAN, NETATMO_CREATE_LIGHT, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SELECT, @@ -69,6 +71,10 @@ PUBLISHERS = { } BATCH_SIZE = 3 +DEV_FACTOR = 7 +DEV_LIMIT = 400 +CLOUD_FACTOR = 2 +CLOUD_LIMIT = 150 DEFAULT_INTERVALS = { ACCOUNT: 10800, HOME: 300, @@ -126,6 +132,7 @@ class NetatmoDataHandler: """Manages the Netatmo data handling.""" account: pyatmo.AsyncAccount + _interval_factor: int def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize self.""" @@ -135,6 +142,14 @@ class NetatmoDataHandler: self.publisher: dict[str, NetatmoPublisher] = {} self._queue: deque = deque() self._webhook: bool = False + if config_entry.data["auth_implementation"] == cloud.DOMAIN: + self._interval_factor = CLOUD_FACTOR + self._rate_limit = CLOUD_LIMIT + else: + self._interval_factor = DEV_FACTOR + self._rate_limit = DEV_LIMIT + self.poll_start = time() + self.poll_count = 0 async def async_setup(self) -> None: """Set up the Netatmo data handler.""" @@ -167,16 +182,29 @@ class NetatmoDataHandler: We do up to BATCH_SIZE calls in one update in order to minimize the calls on the api service. """ - for data_class in islice(self._queue, 0, BATCH_SIZE): + for data_class in islice(self._queue, 0, BATCH_SIZE * self._interval_factor): if data_class.next_scan > time(): continue if publisher := data_class.name: - self.publisher[publisher].next_scan = time() + data_class.interval + error = await self.async_fetch_data(publisher) - await self.async_fetch_data(publisher) + if error: + self.publisher[publisher].next_scan = ( + time() + data_class.interval * 10 + ) + else: + self.publisher[publisher].next_scan = time() + data_class.interval self._queue.rotate(BATCH_SIZE) + cph = self.poll_count / (time() - self.poll_start) * 3600 + _LOGGER.debug("Calls per hour: %i", cph) + if cph > self._rate_limit: + for publisher in self.publisher.values(): + publisher.next_scan += 60 + if (time() - self.poll_start) > 3600: + self.poll_start = time() + self.poll_count = 0 @callback def async_force_update(self, signal_name: str) -> None: @@ -198,31 +226,29 @@ class NetatmoDataHandler: _LOGGER.debug("%s camera reconnected", MANUFACTURER) self.async_force_update(ACCOUNT) - async def async_fetch_data(self, signal_name: str) -> None: + async def async_fetch_data(self, signal_name: str) -> bool: """Fetch data and notify.""" + self.poll_count += 1 + has_error = False try: await getattr(self.account, self.publisher[signal_name].method)( **self.publisher[signal_name].kwargs ) - except pyatmo.NoDevice as err: + except (pyatmo.NoDevice, pyatmo.ApiError) as err: _LOGGER.debug(err) + has_error = True - except pyatmo.ApiError as err: + except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.debug(err) - - except asyncio.TimeoutError as err: - _LOGGER.debug(err) - return - - except aiohttp.ClientConnectorError as err: - _LOGGER.debug(err) - return + return True for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() + return has_error + async def subscribe( self, publisher: str, @@ -239,10 +265,11 @@ class NetatmoDataHandler: if publisher == "public": kwargs = {"area_id": self.account.register_public_weather_area(**kwargs)} + interval = int(DEFAULT_INTERVALS[publisher] / self._interval_factor) self.publisher[signal_name] = NetatmoPublisher( name=signal_name, - interval=DEFAULT_INTERVALS[publisher], - next_scan=time() + DEFAULT_INTERVALS[publisher], + interval=interval, + next_scan=time() + interval, subscriptions={update_callback}, method=PUBLISHERS[publisher], kwargs=kwargs, @@ -330,6 +357,7 @@ class NetatmoDataHandler: NETATMO_CREATE_SENSOR, ], NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], + NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], } for module in home.modules.values(): if not module.device_category: diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/entity.py similarity index 99% rename from homeassistant/components/netatmo/netatmo_entity_base.py rename to homeassistant/components/netatmo/entity.py index 54915facb3a..e6829604d48 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/entity.py @@ -18,7 +18,7 @@ from .const import DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME from .data_handler import PUBLIC, NetatmoDataHandler -class NetatmoBase(Entity): +class NetatmoBaseEntity(Entity): """Netatmo entity base class.""" _attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py new file mode 100644 index 00000000000..8f22861a249 --- /dev/null +++ b/homeassistant/components/netatmo/fan.py @@ -0,0 +1,87 @@ +"""Support for Netatmo/Bubendorff fans.""" +from __future__ import annotations + +import logging +from typing import Final, cast + +from pyatmo import modules as NaModules + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoBaseEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PERCENTAGE: Final = 50 + +PRESET_MAPPING = {"slow": 1, "fast": 2} +PRESETS = {v: k for k, v in PRESET_MAPPING.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo fan platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoFan(netatmo_device) + _LOGGER.debug("Adding cover %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_FAN, _create_entity) + ) + + +class NetatmoFan(NetatmoBaseEntity, FanEntity): + """Representation of a Netatmo fan.""" + + _attr_preset_modes = ["slow", "fast"] + _attr_supported_features = FanEntityFeature.PRESET_MODE + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize of Netatmo fan.""" + super().__init__(netatmo_device.data_handler) + + self._fan = cast(NaModules.Fan, netatmo_device.device) + + self._id = self._fan.entity_id + self._attr_name = self._device_name = self._fan.name + self._model = self._fan.device_type + self._config_url = CONF_URL_CONTROL + + self._home_id = self._fan.home.entity_id + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + + self._attr_unique_id = f"{self._id}-{self._model}" + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._fan.async_set_fan_speed(PRESET_MAPPING[preset_mode]) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if self._fan.fan_speed is None: + self._attr_preset_mode = None + return + self._attr_preset_mode = PRESETS.get(self._fan.fan_speed) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index b796372fc20..c38aec41564 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -23,7 +23,7 @@ from .const import ( WEBHOOK_PUSH_TYPE, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -61,10 +61,12 @@ async def async_setup_entry( ) -class NetatmoCameraLight(NetatmoBase, LightEntity): +class NetatmoCameraLight(NetatmoBaseEntity, LightEntity): """Representation of a Netatmo Presence camera light.""" + _attr_color_mode = ColorMode.ONOFF _attr_has_entity_name = True + _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( self, @@ -150,7 +152,7 @@ class NetatmoCameraLight(NetatmoBase, LightEntity): self._is_on = bool(self._camera.floodlight == "on") -class NetatmoLight(NetatmoBase, LightEntity): +class NetatmoLight(NetatmoBaseEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" def __init__( @@ -170,10 +172,11 @@ class NetatmoLight(NetatmoBase, LightEntity): self._attr_brightness = 0 self._attr_unique_id = f"{self._id}-light" - self._attr_supported_color_modes: set[str] = set() - - if not self._attr_supported_color_modes and self._dimmer.brightness is not None: - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + if self._dimmer.brightness is not None: + self._attr_color_mode = ColorMode.BRIGHTNESS + else: + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {self._attr_color_mode} self._signal_name = f"{HOME}-{self._home_id}" self._publishers.extend( diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index aee63e60016..98734bcb742 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.2"] + "requirements": ["pyatmo==8.0.3"] } diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index b02c63698f3..2dd88782ac3 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -19,7 +19,7 @@ from .const import ( NETATMO_CREATE_SELECT, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoHome -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ async def async_setup_entry( ) -class NetatmoScheduleSelect(NetatmoBase, SelectEntity): +class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): """Representation a Netatmo thermostat schedule selector.""" def __init__( diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 692a1a806ea..430de8e318c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -51,8 +51,8 @@ from .const import ( SIGNAL_NAME, ) from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .entity import NetatmoBaseEntity from .helper import NetatmoArea -from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -399,7 +399,7 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoWeatherSensor(NetatmoBase, SensorEntity): +class NetatmoWeatherSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo weather/home coach sensor.""" _attr_has_entity_name = True @@ -478,7 +478,7 @@ class NetatmoWeatherSensor(NetatmoBase, SensorEntity): self.async_write_ha_state() -class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): +class NetatmoClimateBatterySensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -525,7 +525,7 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): self._attr_native_value = self._module.battery -class NetatmoSensor(NetatmoBase, SensorEntity): +class NetatmoSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -613,7 +613,7 @@ def process_wifi(strength: int) -> str: return "Full" -class NetatmoRoomSensor(NetatmoBase, SensorEntity): +class NetatmoRoomSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -662,7 +662,7 @@ class NetatmoRoomSensor(NetatmoBase, SensorEntity): self.async_write_ha_state() -class NetatmoPublicSensor(NetatmoBase, SensorEntity): +class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): """Represent a single sensor in a Netatmo.""" _attr_has_entity_name = True diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index a2e2e67db39..730f41afeeb 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ async def async_setup_entry( ) -class NetatmoSwitch(NetatmoBase, SwitchEntity): +class NetatmoSwitch(NetatmoBaseEntity, SwitchEntity): """Representation of a Netatmo switch device.""" def __init__( diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 810e3733fbe..2830c551b80 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -7,23 +7,30 @@ from homeassistant.components.binary_sensor import ( 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 . import ModemData from .const import DOMAIN from .entity import LTEEntity 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, ), ) @@ -36,22 +43,13 @@ async def async_setup_entry( modem_data = hass.data[DOMAIN].get_modem_data(entry.data) async_add_entities( - NetgearLTEBinarySensor(modem_data, sensor) for sensor in BINARY_SENSORS + NetgearLTEBinarySensor(entry, modem_data, sensor) for sensor in BINARY_SENSORS ) class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" - def __init__( - self, - modem_data: ModemData, - entity_description: BinarySensorEntityDescription, - ) -> None: - """Initialize a Netgear LTE binary sensor entity.""" - super().__init__(modem_data, entity_description.key) - self.entity_description = entity_description - @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 33e0aaab749..0ec16ceff9d 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/sensor.py b/homeassistant/components/netgear_lte/sensor.py index b91bb9b561a..4e978a2f964 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, UnitOfInformation, ) from homeassistant.core import HomeAssistant @@ -34,39 +35,100 @@ class NetgearLTESensorEntityDescription(SensorEntityDescription): 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, - native_unit_of_measurement=UnitOfInformation.MEBIBYTES, - value_fn=lambda modem_data: round(modem_data.data.usage / 1024**2, 1), + 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"), - NetgearLTESensorEntityDescription(key="connection_text"), - NetgearLTESensorEntityDescription(key="connection_type"), - NetgearLTESensorEntityDescription(key="current_ps_service_type"), - NetgearLTESensorEntityDescription(key="register_network_display"), - NetgearLTESensorEntityDescription(key="current_band"), - NetgearLTESensorEntityDescription(key="cell_id"), + 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, + ), ) @@ -76,7 +138,9 @@ async def async_setup_entry( """Set up the Netgear LTE sensor.""" modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - async_add_entities(NetgearLTESensor(modem_data, sensor) for sensor in SENSORS) + async_add_entities( + NetgearLTESensor(entry, modem_data, sensor) for sensor in SENSORS + ) class NetgearLTESensor(LTEEntity, SensorEntity): @@ -84,18 +148,9 @@ class NetgearLTESensor(LTEEntity, SensorEntity): entity_description: NetgearLTESensorEntityDescription - def __init__( - self, - modem_data: ModemData, - entity_description: NetgearLTESensorEntityDescription, - ) -> None: - """Initialize a Netgear LTE sensor entity.""" - super().__init__(modem_data, entity_description.key) - self.entity_description = entity_description - @property def native_value(self) -> StateType: """Return the state of the sensor.""" 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.sensor_type) + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 8992fb50670..5719d693d15 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -80,5 +80,59 @@ } } } + }, + "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/nexia/climate.py b/homeassistant/components/nexia/climate.py index e331108f6ba..63caeb445b7 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -107,6 +107,8 @@ NEXIA_SUPPORTED = ( | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -151,6 +153,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 5464a241b7a..0013cd63de1 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.7"] + "requirements": ["nexia==2.0.8"] } diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 9cfe4aa7f70..11d2a85d851 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator -PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) +PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -52,7 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + skip_update=False, ) try: diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index fe4366c334d..64fda8c18ba 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", - "requirements": ["nextcloudmonitor==1.4.0"] + "requirements": ["nextcloudmonitor==1.5.0"] } diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py new file mode 100644 index 00000000000..5d52ac2a48f --- /dev/null +++ b/homeassistant/components/nextcloud/update.py @@ -0,0 +1,51 @@ +"""Update data from Nextcoud.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity, UpdateEntityDescription +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 NextcloudDataUpdateCoordinator +from .entity import NextcloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Nextcloud update entity.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator.data.get("update_available") is None: + return + async_add_entities( + [ + NextcloudUpdateSensor( + coordinator, entry, UpdateEntityDescription(key="update") + ) + ] + ) + + +class NextcloudUpdateSensor(NextcloudEntity, UpdateEntity): + """Represents a Nextcloud update entity.""" + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.coordinator.data.get("system_version") + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.coordinator.data.get( + "update_available_version", self.installed_version + ) + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version: + ver = "-".join(self.latest_version.split(".")[:3]) + return f"https://nextcloud.com/changelog/#{ver}" + return None diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 011b487910f..ca59c7d0e3a 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -47,7 +47,7 @@ from .const import ( CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) -class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): +class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS data API.""" def __init__( @@ -84,7 +84,7 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): raise NotImplementedError("Update method not implemented") -class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): +class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics status data from API.""" async def _async_update_data_internal(self) -> AnalyticsStatus: @@ -92,7 +92,7 @@ class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): return await self.nextdns.get_analytics_status(self.profile_id) -class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): +class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics Dnssec data from API.""" async def _async_update_data_internal(self) -> AnalyticsDnssec: @@ -100,7 +100,7 @@ class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): return await self.nextdns.get_analytics_dnssec(self.profile_id) -class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): +class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics encryption data from API.""" async def _async_update_data_internal(self) -> AnalyticsEncryption: @@ -108,7 +108,7 @@ class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncry return await self.nextdns.get_analytics_encryption(self.profile_id) -class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): +class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics IP versions data from API.""" async def _async_update_data_internal(self) -> AnalyticsIpVersions: @@ -116,7 +116,7 @@ class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVer return await self.nextdns.get_analytics_ip_versions(self.profile_id) -class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): +class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics protocols data from API.""" async def _async_update_data_internal(self) -> AnalyticsProtocols: @@ -124,7 +124,7 @@ class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtoc return await self.nextdns.get_analytics_protocols(self.profile_id) -class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): +class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS connection data from API.""" async def _async_update_data_internal(self) -> Settings: @@ -132,7 +132,7 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): return await self.nextdns.get_settings(self.profile_id) -class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): +class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS connection data from API.""" async def _async_update_data_internal(self) -> ConnectionStatus: diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 38a3a5f825c..3a89f4f6022 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -72,6 +72,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 94a2a76c814..970f53837ea 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.5.2"] + "requirements": ["nibe==2.8.0"] } diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py index 1f3f62835bc..98e075ba3c9 100644 --- a/homeassistant/components/nightscout/config_flow.py +++ b/homeassistant/components/nightscout/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Nightscout integration.""" from asyncio import TimeoutError as AsyncIOTimeoutError import logging +from typing import Any from aiohttp import ClientError, ClientResponseError from py_nightscout import Api as NightscoutAPI @@ -8,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries, exceptions from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .utils import hash_from_url @@ -17,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL): str, vol.Optional(CONF_API_KEY): str}) -async def _validate_input(data): +async def _validate_input(data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" - url = data[CONF_URL] - api_key = data.get(CONF_API_KEY) + url: str = data[CONF_URL] + api_key: str | None = data.get(CONF_API_KEY) try: api = NightscoutAPI(url, api_secret=api_key) status = await api.get_server_status() @@ -40,9 +42,11 @@ 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 = {} + errors: dict[str, str] = {} if user_input is not None: unique_id = hash_from_url(user_input[CONF_URL]) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index f60c70cc67c..851610ee374 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -40,7 +40,7 @@ class NightscoutSensor(SensorEntity): _attr_native_unit_of_measurement = "mg/dL" _attr_icon = "mdi:cloud-question" - def __init__(self, api: NightscoutAPI, name, unique_id) -> None: + def __init__(self, api: NightscoutAPI, name: str, unique_id: str | None) -> None: """Initialize the Nightscout sensor.""" self.api = api self._attr_unique_id = unique_id diff --git a/homeassistant/components/nightscout/utils.py b/homeassistant/components/nightscout/utils.py index 4d262ee6439..ac9ce1a3384 100644 --- a/homeassistant/components/nightscout/utils.py +++ b/homeassistant/components/nightscout/utils.py @@ -1,7 +1,9 @@ """Nightscout util functions.""" +from __future__ import annotations + import hashlib -def hash_from_url(url: str): +def hash_from_url(url: str) -> str: """Hash url to create a unique ID.""" return hashlib.sha256(url.encode("utf-8")).hexdigest() diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index a32ba2e6329..a0cb3c4f8cc 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -87,7 +87,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.BINARY_SENSOR, Platform.BUTTON] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] SIGNAL_UPDATE_LEAF = "nissan_leaf_update" diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 0dafff996d0..726b3fa3db8 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -179,7 +179,7 @@ class NmapDeviceScanner: seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) ) self._scan_lock = asyncio.Lock() - if self._hass.state == CoreState.running: + if self._hass.state is CoreState.running: await self._async_start_scanner() return diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 7041d097f3e..ca8ee08885d 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -81,6 +81,7 @@ class NoboZone(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 # Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False + _enable_turn_on_off_backwards_compatibility = False def __init__(self, zone_id, hub: nobo, override_type) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index a1c519f228f..8e4d5927152 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -30,24 +30,17 @@ from .const import ( SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED, ) -from .model import NotionEntityDescriptionMixin +from .model import NotionEntityDescription -@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(frozen=True) +@dataclass(frozen=True, kw_only=True) class NotionBinarySensorDescription( - BinarySensorEntityDescription, - NotionBinarySensorDescriptionMixin, - NotionEntityDescriptionMixin, + BinarySensorEntityDescription, NotionEntityDescription ): """Describe a Notion binary sensor.""" + on_state: Literal["alarm", "leak", "low", "not_missing", "open"] + BINARY_SENSOR_DESCRIPTIONS = ( NotionBinarySensorDescription( diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index cdfd6e63dad..a774bfdfad3 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -4,8 +4,8 @@ from dataclasses import dataclass from aionotion.sensor.models import ListenerKind -@dataclass(frozen=True) -class NotionEntityDescriptionMixin: - """Define an description mixin Notion entities.""" +@dataclass(frozen=True, kw_only=True) +class NotionEntityDescription: + """Define an description for Notion entities.""" listener_kind: ListenerKind diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 8c4242aec2a..1d2c81addfa 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -16,11 +16,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE -from .model import NotionEntityDescriptionMixin +from .model import NotionEntityDescription -@dataclass(frozen=True) -class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): +@dataclass(frozen=True, kw_only=True) +class NotionSensorDescription(SensorEntityDescription, NotionEntityDescription): """Describe a Notion sensor.""" diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index cea62996e6d..9d1f60e33d1 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_nsw_rfs_incidents"], - "requirements": ["aio-geojson-nsw-rfs-incidents==0.6"] + "requirements": ["aio-geojson-nsw-rfs-incidents==0.7"] } diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 13a46c0b32f..b2ebbfa8485 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -78,6 +78,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_preset_modes = PRESET_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3f17c0b795b..42d95f85937 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -279,7 +279,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class NukiCoordinator(DataUpdateCoordinator[None]): +class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Data Update Coordinator for the Nuki integration.""" def __init__(self, hass, bridge, locks, openers): diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 240bb2dc525..e3b2d129017 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -19,16 +19,22 @@ from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nuki lock binary sensor.""" + """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] - entities = [] + lock_entities = [] + opener_entities = [] for lock in entry_data.locks: if lock.is_door_sensor_activated: - entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) + lock_entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) - async_add_entities(entities) + async_add_entities(lock_entities) + + for opener in entry_data.openers: + opener_entities.extend([NukiRingactionEntity(entry_data.coordinator, opener)]) + + async_add_entities(opener_entities) class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): @@ -70,3 +76,29 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): def is_on(self): """Return true if the door is open.""" return self.door_sensor_state == STATE_DOORSENSOR_OPENED + + +class NukiRingactionEntity(NukiEntity[NukiDevice], BinarySensorEntity): + """Representation of a Nuki Opener Ringaction.""" + + _attr_has_entity_name = True + _attr_translation_key = "ring_action" + _attr_icon = "mdi:bell-ring" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._nuki_device.nuki_id}_ringaction" + + @property + def extra_state_attributes(self): + """Return the device specific state attributes.""" + data = { + ATTR_NUKI_ID: self._nuki_device.nuki_id, + } + return data + + @property + def is_on(self) -> bool: + """Return the value of the ring action state.""" + return self._nuki_device.ring_action_state diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 216b891ac31..beac3cb7f74 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -38,6 +38,11 @@ } }, "entity": { + "binary_sensor": { + "ring_action": { + "name": "Ring Action" + } + }, "lock": { "nuki_lock": { "state_attributes": { diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 55b281e02e1..c95381d09c2 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -424,22 +424,22 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: - assert native_unit_of_measurement - assert unit_of_measurement + if TYPE_CHECKING: + assert native_unit_of_measurement + assert unit_of_measurement value_s = str(value) prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 # Suppress ValueError (Could not convert value to float) with suppress(ValueError): - value_new: float = UNIT_CONVERTERS[device_class].convert( - value, + value_new: float = UNIT_CONVERTERS[device_class].converter_factory( native_unit_of_measurement, unit_of_measurement, - ) + )(value) # Round to the wanted precision - value = method(value_new, prec) + return method(value_new, prec) return value @@ -453,21 +453,22 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: - assert native_unit_of_measurement - assert unit_of_measurement + if TYPE_CHECKING: + assert native_unit_of_measurement + assert unit_of_measurement - value = UNIT_CONVERTERS[device_class].convert( - value, + return UNIT_CONVERTERS[device_class].converter_factory( unit_of_measurement, native_unit_of_measurement, - ) + )(value) return value @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" - assert self.registry_entry + if TYPE_CHECKING: + assert self.registry_entry if ( (number_options := self.registry_entry.options.get(DOMAIN)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a2d7c066af7..071f480f766 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.helpers.deprecation import ( @@ -42,7 +43,11 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter +from homeassistant.util.unit_conversion import ( + BaseUnitConverter, + TemperatureConverter, + VolumeFlowRateConverter, +) ATTR_VALUE = "value" ATTR_MIN = "min" @@ -372,6 +377,14 @@ class NumberDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + WATER = "water" """Water. @@ -464,6 +477,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), + NumberDeviceClass.VOLUME_FLOW_RATE: set(UnitOfVolumeFlowRate), NumberDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, @@ -475,8 +489,9 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.WIND_SPEED: set(UnitOfSpeed), } -UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { +UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, + NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } # These can be removed if no deprecated constant are in this module anymore diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json new file mode 100644 index 00000000000..2ce22fcaa4a --- /dev/null +++ b/homeassistant/components/number/icons.json @@ -0,0 +1,151 @@ +{ + "entity_component": { + "_": { + "default": "mdi:ray-vertex" + }, + "apparent_power": { + "default": "mdi:flash" + }, + "aqi": { + "default": "mdi:air-filter" + }, + "atmospheric_pressure": { + "default": "mdi:thermometer-lines" + }, + "battery": { + "default": "mdi:battery" + }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "current": { + "default": "mdi:current-ac" + }, + "data_rate": { + "default": "mdi:transmission-tower" + }, + "data_size": { + "default": "mdi:database" + }, + "distance": { + "default": "mdi:arrow-left-right" + }, + "duration": { + "default": "mdi:progress-clock" + }, + "energy": { + "default": "mdi:lightning-bolt" + }, + "energy_storage": { + "default": "mdi:car-battery" + }, + "frequency": { + "default": "mdi:sine-wave" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "humidity": { + "default": "mdi:water-percent" + }, + "illuminance": { + "default": "mdi:brightness-5" + }, + "irradiance": { + "default": "mdi:sun-wireless" + }, + "moisture": { + "default": "mdi:water-percent" + }, + "monetary": { + "default": "mdi:cash" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "nitrogen_monoxide": { + "default": "mdi:molecule" + }, + "nitrous_oxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "ph": { + "default": "mdi:ph" + }, + "pm1": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "power": { + "default": "mdi:flash" + }, + "power_factor": { + "default": "mdi:angle-acute" + }, + "precipitation": { + "default": "mdi:weather-rainy" + }, + "precipitation_intensity": { + "default": "mdi:weather-pouring" + }, + "pressure": { + "default": "mdi:gauge" + }, + "reactive_power": { + "default": "mdi:flash" + }, + "signal_strength": { + "default": "mdi:wifi" + }, + "sound_pressure": { + "default": "mdi:ear-hearing" + }, + "speed": { + "default": "mdi:speedometer" + }, + "sulfur_dioxide": { + "default": "mdi:molecule" + }, + "temperature": { + "default": "mdi:thermometer" + }, + "volatile_organic_compounds": { + "default": "mdi:molecule" + }, + "volatile_organic_compounds_parts": { + "default": "mdi:molecule" + }, + "voltage": { + "default": "mdi:sine-wave" + }, + "volume": { + "default": "mdi:car-coolant-level" + }, + "volume_storage": { + "default": "mdi:storage-tank" + }, + "water": { + "default": "mdi:water" + }, + "weight": { + "default": "mdi:weight" + }, + "wind_speed": { + "default": "mdi:weather-windy" + } + }, + "services": { + "set_value": "mdi:numeric" + } +} diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 2d72cdbf203..ffddc0c2b3c 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -148,6 +148,9 @@ "volume_storage": { "name": "[%key:component::sensor::entity_component::volume_storage::name%]" }, + "volume_flow_rate": { + "name": "[%key:component::sensor::entity_component::volume_flow_rate::name%]" + }, "water": { "name": "[%key:component::sensor::entity_component::water::name%]" }, diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index c897542e666..13951a44d90 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -39,6 +39,8 @@ STATE_TYPES = { "BOOST": "Boosting Voltage", "FSD": "Forced Shutdown", "ALARM": "Alarm", + "HE": "ECO Mode", + "TEST": "Battery Testing", } COMMAND_BEEPER_DISABLE = "beeper.disable" diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json new file mode 100644 index 00000000000..a4125d8633f --- /dev/null +++ b/homeassistant/components/nut/icons.json @@ -0,0 +1,120 @@ +{ + "entity": { + "sensor": { + "ups_status_display": { + "default": "mdi:information-outline" + }, + "ups_status": { + "default": "mdi:information-outline" + }, + "ups_alarm": { + "default": "mdi:alarm" + }, + "ups_load": { + "default": "mdi:gauge" + }, + "ups_load_high": { + "default": "mdi:gauge" + }, + "ups_id": { + "default": "mdi:information-outline" + }, + "ups_test_result": { + "default": "mdi:information-outline" + }, + "ups_test_date": { + "default": "mdi:calendar" + }, + "ups_display_language": { + "default": "mdi:information-outline" + }, + "ups_contacts": { + "default": "mdi:information-outline" + }, + "ups_efficiency": { + "default": "mdi:gauge" + }, + "ups_beeper_status": { + "default": "mdi:information-outline" + }, + "ups_type": { + "default": "mdi:information-outline" + }, + "ups_watchdog_status": { + "default": "mdi:information-outline" + }, + "ups_start_auto": { + "default": "mdi:information-outline" + }, + "ups_start_battery": { + "default": "mdi:information-outline" + }, + "ups_start_reboot": { + "default": "mdi:information-outline" + }, + "ups_shutdown": { + "default": "mdi:information-outline" + }, + "battery_charge_low": { + "default": "mdi:gauge" + }, + "battery_charge_restart": { + "default": "mdi:gauge" + }, + "battery_charge_warning": { + "default": "mdi:gauge" + }, + "battery_charger_status": { + "default": "mdi:information-outline" + }, + "battery_capacity": { + "default": "mdi:flash" + }, + "battery_alarm_threshold": { + "default": "mdi:information-outline" + }, + "battery_date": { + "default": "mdi:calendar" + }, + "battery_mfr_date": { + "default": "mdi:calendar" + }, + "battery_packs": { + "default": "mdi:information-outline" + }, + "battery_packs_bad": { + "default": "mdi:information-outline" + }, + "battery_type": { + "default": "mdi:information-outline" + }, + "input_sensitivity": { + "default": "mdi:information-outline" + }, + "input_transfer_reason": { + "default": "mdi:information-outline" + }, + "input_frequency_status": { + "default": "mdi:information-outline" + }, + "input_bypass_phases": { + "default": "mdi:information-outline" + }, + "input_phases": { + "default": "mdi:information-outline" + }, + "output_l1_power_percent": { + "default": "mdi:gauge" + }, + "output_l2_power_percent": { + "default": "mdi:gauge" + }, + "output_l3_power_percent": { + "default": "mdi:gauge" + }, + "output_phases": { + "default": "mdi:information-outline" + } + } + } +} diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 165db8bb704..e4721d2d41c 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -58,17 +58,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", translation_key="ups_status_display", - icon="mdi:information-outline", ), "ups.status": SensorEntityDescription( key="ups.status", translation_key="ups_status", - icon="mdi:information-outline", ), "ups.alarm": SensorEntityDescription( key="ups.alarm", translation_key="ups_alarm", - icon="mdi:alarm", ), "ups.temperature": SensorEntityDescription( key="ups.temperature", @@ -83,21 +80,18 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.load", translation_key="ups_load", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), "ups.load.high": SensorEntityDescription( key="ups.load.high", translation_key="ups_load_high", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.id": SensorEntityDescription( key="ups.id", translation_key="ups_id", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -160,28 +154,24 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.test.result": SensorEntityDescription( key="ups.test.result", translation_key="ups_test_result", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", translation_key="ups_test_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", translation_key="ups_display_language", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", translation_key="ups_contacts", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -189,7 +179,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.efficiency", translation_key="ups_efficiency", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -231,49 +220,42 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", translation_key="ups_beeper_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.type": SensorEntityDescription( key="ups.type", translation_key="ups_type", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", translation_key="ups_watchdog_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", translation_key="ups_start_auto", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", translation_key="ups_start_battery", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", translation_key="ups_start_reboot", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", translation_key="ups_shutdown", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -288,7 +270,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.low", translation_key="battery_charge_low", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -296,7 +277,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.restart", translation_key="battery_charge_restart", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -304,14 +284,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.warning", translation_key="battery_charge_warning", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", translation_key="battery_charger_status", - icon="mdi:information-outline", ), "battery.voltage": SensorEntityDescription( key="battery.voltage", @@ -350,7 +328,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.capacity", translation_key="battery_capacity", native_unit_of_measurement="Ah", - icon="mdi:flash", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -407,49 +384,42 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", translation_key="battery_alarm_threshold", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.date": SensorEntityDescription( key="battery.date", translation_key="battery_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", translation_key="battery_mfr_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs": SensorEntityDescription( key="battery.packs", translation_key="battery_packs", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", translation_key="battery_packs_bad", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.type": SensorEntityDescription( key="battery.type", translation_key="battery_type", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", translation_key="input_sensitivity", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -472,7 +442,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", translation_key="input_transfer_reason", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -538,7 +507,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", translation_key="input_frequency_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -617,7 +585,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.bypass.phases": SensorEntityDescription( key="input.bypass.phases", translation_key="input_bypass_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -732,7 +699,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -784,7 +750,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L1.power.percent", translation_key="output_l1_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -792,7 +757,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L2.power.percent", translation_key="output_l2_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -800,7 +764,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L3.power.percent", translation_key="output_l3_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -910,7 +873,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.phases": SensorEntityDescription( key="output.phases", translation_key="output_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 063ecdabab2..da54f3b119e 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -45,7 +45,7 @@ class NWSData: coordinator_forecast_hourly: NwsDataUpdateCoordinator -class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): +class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """NWS data update coordinator. Implements faster data update intervals for failed updates and exposes a last successful update time. diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 696898400bf..01a3e9518c0 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -53,12 +53,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 api_key_task: asyncio.Task[None] | None = None + discovery_schema: vol.Schema | None = None _reauth_data: dict[str, Any] | None = None + _user_input: dict[str, Any] | None = None def __init__(self) -> None: """Handle a config flow for OctoPrint.""" - self.discovery_schema = None - self._user_input = None self._sessions: list[aiohttp.ClientSession] = [] async def async_step_user(self, user_input=None): @@ -97,17 +97,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), ) - self.api_key_task = None - return await self.async_step_get_api_key(user_input) + self._user_input = user_input + return await self.async_step_get_api_key() - async def async_step_get_api_key(self, user_input): + async def async_step_get_api_key(self, user_input=None): """Get an Application Api Key.""" if not self.api_key_task: - self.api_key_task = self.hass.async_create_task( - self._async_get_auth_key(user_input) - ) + self.api_key_task = self.hass.async_create_task(self._async_get_auth_key()) + if not self.api_key_task.done(): return self.async_show_progress( - step_id="get_api_key", progress_action="get_api_key" + step_id="get_api_key", + progress_action="get_api_key", + progress_task=self.api_key_task, ) try: @@ -118,9 +119,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Failed to get an application key : %s", err) return self.async_show_progress_done(next_step_id="auth_failed") + finally: + self.api_key_task = None - # store this off here to pick back up in the user step - self._user_input = user_input return self.async_show_progress_done(next_step_id="user") async def _finish_config(self, user_input: dict): @@ -238,26 +239,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), ) - self.api_key_task = None self._reauth_data[CONF_USERNAME] = user_input[CONF_USERNAME] - return await self.async_step_get_api_key(self._reauth_data) + self._user_input = self._reauth_data + return await self.async_step_get_api_key() - async def _async_get_auth_key(self, user_input: dict): + async def _async_get_auth_key(self): """Get application api key.""" - octoprint = self._get_octoprint_client(user_input) + octoprint = self._get_octoprint_client(self._user_input) - try: - user_input[CONF_API_KEY] = await octoprint.request_app_key( - "Home Assistant", user_input[CONF_USERNAME], 300 - ) - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure( - flow_id=self.flow_id, user_input=user_input - ) - ) + self._user_input[CONF_API_KEY] = await octoprint.request_app_key( + "Home Assistant", self._user_input[CONF_USERNAME], 300 + ) def _get_octoprint_client(self, user_input: dict) -> OctoprintClient: """Build an octoprint client from the user_input.""" diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 1b600b25d94..86c770ec82d 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -66,8 +66,13 @@ class ThermostatDevice(ClimateEntity): """Interface class for the oemthermostat module.""" _attr_hvac_modes = SUPPORT_HVAC - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat, name): """Initialize the device.""" diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 4e64a219f77..3fbd53d20f2 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -20,7 +20,7 @@ from .const import ALL_ITEM_KINDS, DOMAIN _LOGGER = logging.getLogger(__name__) -class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): +class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching update data from single endpoint.""" def __init__( diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index d334a0051c3..4243d05c085 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -1,4 +1,6 @@ """Support to help onboard new users.""" +from __future__ import annotations + from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback @@ -23,10 +25,15 @@ STORAGE_VERSION = 4 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -class OnboadingStorage(Store): +class OnboadingStorage(Store[dict[str, list[str]]]): """Store onboarding data.""" - async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, list[str]], + ) -> dict[str, list[str]]: """Migrate to the new version.""" # From version 1 -> 2, we automatically mark the integration step done if old_major_version < 2: @@ -56,6 +63,7 @@ def async_is_user_onboarded(hass: HomeAssistant) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the onboarding component.""" store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) + data: dict[str, list[str]] | None if (data := await store.async_load()) is None: data = {"done": []} diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 05467e96860..e1edfa82a62 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,21 +3,27 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from typing import TYPE_CHECKING, Any, cast +from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth from homeassistant.components.http import KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import area_registry as ar from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations +if TYPE_CHECKING: + from . import OnboadingStorage + from .const import ( DEFAULT_AREAS, DOMAIN, @@ -29,7 +35,9 @@ from .const import ( ) -async def async_setup(hass, data, store): +async def async_setup( + hass: HomeAssistant, data: dict[str, list[str]], store: OnboadingStorage +) -> None: """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) @@ -46,12 +54,12 @@ class OnboardingView(HomeAssistantView): url = "/api/onboarding" name = "api:onboarding" - def __init__(self, data, store): + def __init__(self, data: dict[str, list[str]], store: OnboadingStorage) -> None: """Initialize the onboarding view.""" self._store = store self._data = data - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" return self.json( [{"step": key, "done": key in self._data["done"]} for key in STEPS] @@ -65,16 +73,16 @@ class InstallationTypeOnboardingView(HomeAssistantView): url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data): + def __init__(self, data: dict[str, list[str]]) -> None: """Initialize the onboarding installation type view.""" self._data = data - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: raise HTTPUnauthorized() - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] info = await async_get_system_info(hass) return self.json({"installation_type": info["installation_type"]}) @@ -82,20 +90,20 @@ class InstallationTypeOnboardingView(HomeAssistantView): class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" - step: str | None = None + step: str - def __init__(self, data, store): + def __init__(self, data: dict[str, list[str]], store: OnboadingStorage) -> None: """Initialize the onboarding view.""" self._store = store self._data = data self._lock = asyncio.Lock() @callback - def _async_is_done(self): + def _async_is_done(self) -> bool: """Return if this step is done.""" return self.step in self._data["done"] - async def _async_mark_done(self, hass): + async def _async_mark_done(self, hass: HomeAssistant) -> None: """Mark step as done.""" self._data["done"].append(self.step) await self._store.async_save(self._data) @@ -123,9 +131,9 @@ class UserOnboardingView(_BaseOnboardingView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle user creation, area creation.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): @@ -137,13 +145,10 @@ class UserOnboardingView(_BaseOnboardingView): user = await hass.auth.async_create_user( data["name"], group_ids=[GROUP_ID_ADMIN] ) - await hass.async_add_executor_job( - provider.data.add_auth, data["username"], data["password"] - ) + await provider.async_add_auth(data["username"], data["password"]) credentials = await provider.async_get_or_create_credentials( {"username": data["username"]} ) - await provider.data.async_save() await hass.auth.async_link_user(user, credentials) if "person" in hass.config.components: await person.async_create_person(hass, data["name"], user_id=user.id) @@ -180,9 +185,9 @@ class CoreConfigOnboardingView(_BaseOnboardingView): name = "api:onboarding:core_config" step = STEP_CORE_CONFIG - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle finishing core config step.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): @@ -205,7 +210,8 @@ class CoreConfigOnboardingView(_BaseOnboardingView): if ( hassio.is_hassio(hass) - and "raspberrypi" in hassio.get_core_info(hass)["machine"] + and (core_info := hassio.get_core_info(hass)) + and "raspberrypi" in core_info["machine"] ): onboard_integrations.append("rpi_power") @@ -232,9 +238,9 @@ class IntegrationOnboardingView(_BaseOnboardingView): @RequestDataValidator( vol.Schema({vol.Required("client_id"): str, vol.Required("redirect_uri"): str}) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle token creation.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] refresh_token_id = request[KEY_HASS_REFRESH_TOKEN_ID] async with self._lock: @@ -253,7 +259,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) - refresh_token = await hass.auth.async_get_refresh_token(refresh_token_id) + refresh_token = hass.auth.async_get_refresh_token(refresh_token_id) if refresh_token is None or refresh_token.credential is None: return self.json_message( "Credentials for user not available", HTTPStatus.FORBIDDEN @@ -276,9 +282,9 @@ class AnalyticsOnboardingView(_BaseOnboardingView): name = "api:onboarding:analytics" step = STEP_ANALYTICS - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle finishing analytics step.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): @@ -292,10 +298,10 @@ class AnalyticsOnboardingView(_BaseOnboardingView): @callback -def _async_get_hass_provider(hass): +def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" for prv in hass.auth.auth_providers: if prv.type == "homeassistant": - return prv + return cast(HassAuthProvider, prv) raise RuntimeError("No Home Assistant provider found") diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index e0342c5f0d4..9688a78bf3f 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -146,11 +146,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): configure_unique_id=False ) if not errors: - hass = self.hass - entry_id = entry.entry_id - hass.config_entries.async_update_entry(entry, data=self.onvif_config) - hass.async_create_task(hass.config_entries.async_reload(entry_id)) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=self.onvif_config) username = (user_input or {}).get(CONF_USERNAME) or entry.data[CONF_USERNAME] return self.async_show_form( diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index c269ee53cf3..46d018ec1af 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -18,13 +18,11 @@ from .const import CONF_DEVICE_KEY, DOMAIN _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) @@ -52,7 +50,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Opengarage data.""" def __init__( diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py new file mode 100644 index 00000000000..9f676919098 --- /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/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index a4a16c6713c..4935af1bc46 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -154,7 +154,7 @@ class OpenhomeDevice(MediaPlayerEntity): self._source_index = source_index self._attr_source_list = source_names - if source["type"] == "Radio": + if source["type"] in ("Radio", "Receiver"): self._attr_supported_features |= ( MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index a0cd6bc54c2..87621ea3508 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -25,10 +25,14 @@ from homeassistant.core import callback 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.typing import ConfigType -from .const import CONF_CONTRIBUTING_USER, DEFAULT_NAME, DOMAIN -from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE +from .const import ( + CONF_ALTITUDE, + CONF_CONTRIBUTING_USER, + DEFAULT_ALTITUDE, + DEFAULT_NAME, + DOMAIN, +) class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -77,24 +81,6 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_config: ConfigType) -> FlowResult: - """Import config from yaml.""" - entry_data = { - CONF_LATITUDE: import_config.get(CONF_LATITUDE, self.hass.config.latitude), - CONF_LONGITUDE: import_config.get( - CONF_LONGITUDE, self.hass.config.longitude - ), - } - self._async_abort_entries_match(entry_data) - return self.async_create_entry( - title=import_config.get(CONF_NAME, DEFAULT_NAME), - data=entry_data, - options={ - CONF_RADIUS: import_config[CONF_RADIUS] * 1000, - CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), - }, - ) - class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): """OpenSky Options flow handler.""" diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index e6a165b36ee..9cae0366357 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -1,66 +1,16 @@ """Sensor for the Open Sky Network.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ALTITUDE, DEFAULT_ALTITUDE, DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import OpenSkyDataUpdateCoordinator -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_RADIUS): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string, - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_ALTITUDE, default=DEFAULT_ALTITUDE): vol.Coerce(float), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the OpenSky sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "OpenSky", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index bcad621eb82..0b9cd1862be 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -84,6 +84,7 @@ class OpenThermClimate(ClimateEntity): _away_state_a = False _away_state_b = False _current_operation: HVACAction | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, gw_dev, options): """Initialize the device.""" diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 7dc2d206912..82d982b2fa9 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -51,6 +51,8 @@ TRANSLATE_SOURCE = { gw_vars.THERMOSTAT: "Thermostat", } +SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION = 1 + BINARY_SENSOR_INFO: dict[str, list] = { # [device_class, friendly_name format, [status source, ...]] gw_vars.DATA_MASTER_CH_ENABLED: [ @@ -214,324 +216,453 @@ BINARY_SENSOR_INFO: dict[str, list] = { } SENSOR_INFO: dict[str, list] = { - # [device_class, unit, friendly_name, [status source, ...]] + # [device_class, unit, friendly_name, suggested_display_precision, [status source, ...]] gw_vars.DATA_CONTROL_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_MEMBERID: [ None, None, "Thermostat Member ID {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MEMBERID: [ None, None, "Boiler Member ID {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_OEM_FAULT: [ None, None, "Boiler OEM Fault Code {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_COOLING_CONTROL: [ None, PERCENTAGE, "Cooling Control Signal {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CONTROL_SETPOINT_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT_OVRD: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MAX_CAPACITY: [ SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ None, PERCENTAGE, "Boiler Minimum Modulation Level {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_REL_MOD_LEVEL: [ None, PERCENTAGE, "Relative Modulation Level {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_PRESS: [ SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_FLOW_RATE: [ None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_OUTSIDE_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_RETURN_WATER_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SOLAR_STORAGE_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SOLAR_COLL_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_TEMP_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_TEMP_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_EXHAUST_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_DHW_MAX_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_DHW_MIN_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_CH_MAX_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_CH_MIN_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MAX_CH_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_OEM_DIAG: [ None, None, "OEM Diagnostic Code {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_TOTAL_BURNER_STARTS: [ None, "starts", "Total Burner Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_STARTS: [ None, "starts", "Central Heating Pump Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_STARTS: [ None, "starts", "Hot Water Pump Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_STARTS: [ None, "starts", "Hot Water Burner Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_TOTAL_BURNER_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_OT_VERSION: [ None, None, "Thermostat OpenTherm Version {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_OT_VERSION: [ None, None, "Boiler OpenTherm Version {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_PRODUCT_TYPE: [ None, None, "Thermostat Product Type {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_PRODUCT_VERSION: [ None, None, "Thermostat Product Version {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_PRODUCT_TYPE: [ None, None, "Boiler Product Type {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_PRODUCT_VERSION: [ None, None, "Boiler Product Version {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], - gw_vars.OTGW_MODE: [None, None, "Gateway/Monitor Mode {}", [gw_vars.OTGW]], + gw_vars.OTGW_MODE: [ + None, + None, + "Gateway/Monitor Mode {}", + None, + [gw_vars.OTGW], + ], gw_vars.OTGW_DHW_OVRD: [ None, None, "Gateway Hot Water Override Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_ABOUT: [ + None, + None, + "Gateway Firmware Version {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_BUILD: [ + None, + None, + "Gateway Firmware Build {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_CLOCKMHZ: [ + None, + None, + "Gateway Clock Speed {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_A: [ + None, + None, + "Gateway LED A Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_B: [ + None, + None, + "Gateway LED B Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_C: [ + None, + None, + "Gateway LED C Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_D: [ + None, + None, + "Gateway LED D Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_E: [ + None, + None, + "Gateway LED E Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_F: [ + None, + None, + "Gateway LED F Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_GPIO_A: [ + None, + None, + "Gateway GPIO A Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_GPIO_B: [ + None, + None, + "Gateway GPIO B Mode {}", + None, [gw_vars.OTGW], ], - gw_vars.OTGW_ABOUT: [None, None, "Gateway Firmware Version {}", [gw_vars.OTGW]], - gw_vars.OTGW_BUILD: [None, None, "Gateway Firmware Build {}", [gw_vars.OTGW]], - gw_vars.OTGW_CLOCKMHZ: [None, None, "Gateway Clock Speed {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_A: [None, None, "Gateway LED A Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_B: [None, None, "Gateway LED B Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_C: [None, None, "Gateway LED C Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_D: [None, None, "Gateway LED D Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_E: [None, None, "Gateway LED E Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_F: [None, None, "Gateway LED F Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_GPIO_A: [None, None, "Gateway GPIO A Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_GPIO_B: [None, None, "Gateway GPIO B Mode {}", [gw_vars.OTGW]], gw_vars.OTGW_SB_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.OTGW], ], gw_vars.OTGW_SETP_OVRD_MODE: [ None, None, "Gateway Room Setpoint Override Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_SMART_PWR: [ + None, + None, + "Gateway Smart Power Mode {}", + None, [gw_vars.OTGW], ], - gw_vars.OTGW_SMART_PWR: [None, None, "Gateway Smart Power Mode {}", [gw_vars.OTGW]], gw_vars.OTGW_THRM_DETECT: [ None, None, "Gateway Thermostat Detection {}", + None, [gw_vars.OTGW], ], gw_vars.OTGW_VREF: [ None, None, "Gateway Reference Voltage Setting {}", + None, [gw_vars.OTGW], ], } diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 09fbb0ef6ee..5848d50ad95 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -28,7 +28,8 @@ async def async_setup_entry( device_class = info[0] unit = info[1] friendly_name_format = info[2] - status_sources = info[3] + suggested_display_precision = info[3] + status_sources = info[4] for source in status_sources: sensors.append( @@ -39,6 +40,7 @@ async def async_setup_entry( device_class, unit, friendly_name_format, + suggested_display_precision, ) ) @@ -51,7 +53,16 @@ class OpenThermSensor(SensorEntity): _attr_should_poll = False _attr_entity_registry_enabled_default = False - def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format): + def __init__( + self, + gw_dev, + var, + source, + device_class, + unit, + friendly_name_format, + suggested_display_precision, + ): """Initialize the OpenTherm Gateway sensor.""" self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, f"{var}_{source}_{gw_dev.gw_id}", hass=gw_dev.hass @@ -68,6 +79,8 @@ class OpenThermSensor(SensorEntity): self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + if suggested_display_precision: + self._attr_suggested_display_precision = suggested_display_precision self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, gw_dev.gw_id)}, manufacturer="Schelte Bron", @@ -97,7 +110,5 @@ class OpenThermSensor(SensorEntity): def receive_report(self, status): """Handle status updates from the component.""" value = status[self._source].get(self._var) - if isinstance(value, float): - value = f"{value:2.1f}" self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 431fa41a288..9e337d49ba3 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -71,20 +71,13 @@ def get_uv_label(uv_index: int) -> str: return label.value -@dataclass(frozen=True) -class OpenUvSensorEntityDescriptionMixin: - """Define a mixin for OpenUV sensor descriptions.""" +@dataclass(frozen=True, kw_only=True) +class OpenUvSensorEntityDescription(SensorEntityDescription): + """Define a class that describes OpenUV sensor entities.""" value_fn: Callable[[dict[str, Any]], int | str] -@dataclass(frozen=True) -class OpenUvSensorEntityDescription( - SensorEntityDescription, OpenUvSensorEntityDescriptionMixin -): - """Define a class that describes OpenUV sensor entities.""" - - SENSOR_DESCRIPTIONS = ( OpenUvSensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 56519c46fd9..05b24d60f79 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -60,7 +60,7 @@ _LOGGER = logging.getLogger(__name__) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) -class WeatherUpdateCoordinator(DataUpdateCoordinator): +class WeatherUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Weather data update coordinator.""" def __init__(self, owm, latitude, longitude, forecast_mode, hass): diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 89b62912710..e654c044c16 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.1.0"] + "requirements": ["opower==0.2.0"] } diff --git a/homeassistant/components/oralb/__init__.py b/homeassistant/components/oralb/__init__.py index c981ad01bd8..23a022effef 100644 --- a/homeassistant/components/oralb/__init__.py +++ b/homeassistant/components/oralb/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from oralb_ble import OralBBluetoothDeviceData +from oralb_ble import OralBBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - hass.state == CoreState.running + hass.state is CoreState.running and data.poll_needed(service_info, last_poll) and bool( async_ble_device_from_address( @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_poll(service_info: BluetoothServiceInfoBleak): + async def _async_poll(service_info: BluetoothServiceInfoBleak) -> SensorUpdate: # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it # directly to the oralb code # Make sure the device we have is one that we can connect with diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py index a7632b19bcb..28b037f9cc5 100644 --- a/homeassistant/components/osoenergy/config_flow.py +++ b/homeassistant/components/osoenergy/config_flow.py @@ -22,7 +22,6 @@ 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.""" diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 0f4374d95bd..3c08a74ed61 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() + extended_address = await otbrdata.get_extended_address() except ( HomeAssistantError, aiohttp.ClientError, @@ -62,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, dataset_tlvs.hex(), preferred_border_agent_id=border_agent_id.hex(), + preferred_extended_address=extended_address.hex(), ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 35772c00a89..b96e276af8b 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -28,7 +28,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN -from .util import get_allowed_channel +from .util import ( + compose_default_network_name, + generate_random_pan_id, + get_allowed_channel, +) _LOGGER = logging.getLogger(__name__) @@ -85,10 +89,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug( "not importing TLV with channel %s", thread_dataset_channel ) + pan_id = generate_random_pan_id() await api.create_active_dataset( python_otbr_api.ActiveDataSet( channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, - network_name="home-assistant", + network_name=compose_default_network_name(pan_id), + pan_id=pan_id, ) ) await api.set_enabled(True) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index cf6aba33e80..ca0faa160f0 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0"] + "requirements": ["python-otbr-api==2.6.0"] } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 85e97209a44..9c47df5eaf7 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Coroutine import dataclasses from functools import wraps import logging +import random from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api @@ -48,6 +49,17 @@ INSECURE_PASSPHRASES = ( ) +def compose_default_network_name(pan_id: int) -> str: + """Generate a default network name.""" + return f"ha-thread-{pan_id:04x}" + + +def generate_random_pan_id() -> int: + """Generate a random PAN ID.""" + # PAN ID is 2 bytes, 0xffff is reserved for broadcast + return random.randint(0, 0xFFFE) + + def _handle_otbr_error( func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 0693bc3a325..163152a4bff 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -16,7 +16,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN -from .util import OTBRData, get_allowed_channel, update_issues +from .util import ( + OTBRData, + compose_default_network_name, + generate_random_pan_id, + get_allowed_channel, + update_issues, +) @callback @@ -99,10 +105,13 @@ async def websocket_create_network( connection.send_error(msg["id"], "factory_reset_failed", str(exc)) return + pan_id = generate_random_pan_id() try: await data.create_active_dataset( python_otbr_api.ActiveDataSet( - channel=channel, network_name="home-assistant" + channel=channel, + network_name=compose_default_network_name(pan_id), + pan_id=pan_id, ) ) except HomeAssistantError as exc: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index 46a330c97cc..2678986574d 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -46,9 +46,14 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 5807ccecd74..36e958fb49c 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -69,9 +69,13 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index 0c378d088c5..fefaa75a114 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -45,6 +45,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -55,7 +56,11 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): TEMPERATURE_SENSOR_DEVICE_INDEX ) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py index 1da7c48f9eb..5876f7df4a7 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -48,9 +48,13 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): _attr_preset_modes = [PRESET_AUTO, PRESET_PROG, PRESET_MANUAL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index 7722269a48b..25dab7c1d7e 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -76,10 +76,14 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index 74f7637b997..fe9f20b05fc 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -3,7 +3,11 @@ from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState -from homeassistant.components.climate import ClimateEntity, HVACMode +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) from homeassistant.const import UnitOfTemperature from ..entity import OverkizEntity @@ -23,6 +27,10 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: 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 index 7a9e50d7130..9b956acd014 100644 --- 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 @@ -90,6 +90,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -101,6 +102,8 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self.device.states.get(SWING_STATE): diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py index 6c3ee3454ce..f98865456e1 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py @@ -71,13 +71,17 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 4059f8521b8..2b6840b463d 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -56,11 +56,15 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index 3d883738de2..7b7493a37bb 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -55,6 +55,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index e6178ffeb41..18c58525097 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -67,7 +67,7 @@ class P1MonitorData(TypedDict): watermeter: WaterMeter | None -class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching P1 Monitor data from single endpoint.""" config_entry: ConfigEntry diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 17fba104c7a..587dc980e41 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -321,4 +321,4 @@ class P1MonitorSensorEntity( ) if isinstance(value, str): return value.lower() - return value + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/pegel_online/icons.json b/homeassistant/components/pegel_online/icons.json new file mode 100644 index 00000000000..b3192ba5283 --- /dev/null +++ b/homeassistant/components/pegel_online/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "air_temperature": { + "default": "mdi:thermometer-lines" + }, + "clearance_height": { + "default": "mdi:bridge" + }, + "oxygen_level": { + "default": "mdi:water-opacity" + }, + "water_speed": { + "default": "mdi:waves-arrow-right" + }, + "water_flow": { + "default": "mdi:waves" + }, + "water_level": { + "default": "mdi:waves-arrow-up" + }, + "water_temperature": { + "default": "mdi:thermometer-water" + } + } + } +} diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 5f7f431ddf7..657baf29c9f 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -42,7 +42,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="air_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer-lines", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -51,14 +50,12 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="clearance_height", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, - icon="mdi:bridge", ), PegelOnlineSensorEntityDescription( key="oxygen_level", translation_key="oxygen_level", measurement_key="oxygen_level", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:water-opacity", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -74,7 +71,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="water_speed", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.SPEED, - icon="mdi:waves-arrow-right", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -82,7 +78,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( translation_key="water_flow", measurement_key="water_flow", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:waves", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -90,7 +85,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( translation_key="water_level", measurement_key="water_level", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:waves-arrow-up", ), PegelOnlineSensorEntityDescription( key="water_temperature", @@ -98,7 +92,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="water_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer-water", entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 644ea29d8a3..2e3e228d512 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -5,7 +5,12 @@ from collections.abc import Mapping import logging from typing import Any -from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException +from mypermobil import ( + MyPermobil, + MyPermobilAPIException, + MyPermobilClientException, + MyPermobilEulaException, +) import voluptuous as vol from homeassistant import config_entries @@ -141,10 +146,16 @@ class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # or the backend returned an error when trying to validate the code _LOGGER.exception("Error verifying code") errors["base"] = "invalid_code" + except MyPermobilEulaException: + # The user has not accepted the EULA + errors["base"] = "unsigned_eula" if errors or not user_input: return self.async_show_form( - step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors + step_id="email_code", + data_schema=GET_TOKEN_SCHEMA, + errors=errors, + description_placeholders={"app_name": "MyPermobil"}, ) return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index fd937fc6f8a..2d136b28713 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/permobil", "iot_class": "cloud_polling", - "requirements": ["mypermobil==0.1.6"] + "requirements": ["mypermobil==0.1.8"] } diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index a48741b0886..8a504248f5a 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -15,6 +15,8 @@ from mypermobil import ( BATTERY_MAX_DISTANCE_LEFT, BATTERY_STATE_OF_CHARGE, BATTERY_STATE_OF_HEALTH, + RECORDS_DISTANCE, + RECORDS_DISTANCE_UNIT, RECORDS_SEATING, USAGE_ADJUSTMENTS, USAGE_DISTANCE, @@ -32,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN +from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES from .coordinator import MyPermobilCoordinator _LOGGER = logging.getLogger(__name__) @@ -159,7 +161,7 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), PermobilSensorEntityDescription( - # Largest number of adjustemnts in a single 24h period, never resets + # 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", @@ -168,8 +170,22 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( 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, @@ -209,6 +225,15 @@ class PermobilSensor(CoordinatorEntity[MyPermobilCoordinator], SensorEntity): 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.""" diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index b0b630eff08..5070c13d9e5 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -27,7 +27,8 @@ "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" + "invalid_code": "The code you gave is incorrect", + "unsigned_eula": "Please sign the EULA in the {app_name} app" } }, "entity": { @@ -64,6 +65,9 @@ }, "record_adjustments": { "name": "Record number of adjustments" + }, + "record_distance": { + "name": "Record distance" } } } diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 9ecb91bdb7f..6d6fb7bfbd6 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime from enum import StrEnum +from functools import partial import logging from typing import Any, Final, TypedDict @@ -213,6 +214,21 @@ def websocket_get_notifications( ) +@callback +def _async_send_notification_update( + connection: websocket_api.ActiveConnection, + msg_id: int, + update_type: UpdateType, + notifications: dict[str, Notification], +) -> None: + """Send persistent_notification update.""" + connection.send_message( + websocket_api.event_message( + msg_id, {"type": update_type, "notifications": notifications} + ) + ) + + @callback @websocket_api.websocket_command( {vol.Required("type"): "persistent_notification/subscribe"} @@ -225,19 +241,9 @@ def websocket_subscribe_notifications( """Return a list of persistent_notifications.""" notifications = _async_get_or_create_notifications(hass) msg_id = msg["id"] - - @callback - def _async_send_notification_update( - update_type: UpdateType, notifications: dict[str, Notification] - ) -> None: - connection.send_message( - websocket_api.event_message( - msg["id"], {"type": update_type, "notifications": notifications} - ) - ) - + notify_func = partial(_async_send_notification_update, connection, msg_id) connection.subscriptions[msg_id] = async_dispatcher_connect( - hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, notify_func ) connection.send_result(msg_id) - _async_send_notification_update(UpdateType.CURRENT, notifications) + notify_func(UpdateType.CURRENT, notifications) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index c796cb8d843..3a7db248862 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,11 +1,10 @@ """Support for tracking people.""" from __future__ import annotations -from http import HTTPStatus +from collections.abc import Callable import logging -from typing import Any +from typing import Any, Self -from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -15,7 +14,6 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) -from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -49,10 +47,13 @@ from homeassistant.helpers import ( service, ) from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -95,7 +96,13 @@ CONFIG_SCHEMA = vol.Schema( @bind_hass -async def async_create_person(hass, name, *, user_id=None, device_trackers=None): +async def async_create_person( + hass: HomeAssistant, + name: str, + *, + user_id: str | None = None, + device_trackers: list[str] | None = None, +) -> None: """Create a new person.""" await hass.data[DOMAIN][1].async_create_item( { @@ -109,7 +116,7 @@ async def async_create_person(hass, name, *, user_id=None, device_trackers=None) @bind_hass async def async_add_user_device_tracker( hass: HomeAssistant, user_id: str, device_tracker_entity_id: str -): +) -> None: """Add a device tracker to a person linked to a user.""" coll: PersonStorageCollection = hass.data[DOMAIN][1] @@ -184,7 +191,9 @@ UPDATE_FIELDS = { class PersonStore(Store): """Person storage.""" - async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: """Migrate to the new version. Migrate storage to use format of collection helper. @@ -278,14 +287,14 @@ class PersonStorageCollection(collection.DictStorageCollection): """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - user_id = update_data.get(CONF_USER_ID) + user_id: str | None = update_data.get(CONF_USER_ID) if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) return {**item, **update_data} - async def _validate_user_id(self, user_id): + async def _validate_user_id(self, user_id: str) -> None: """Validate the used user_id.""" if await self.hass.auth.async_get_user(user_id) is None: raise ValueError("User does not exist") @@ -388,8 +397,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) - hass.http.register_view(ListPersonsView) - return True @@ -401,32 +408,32 @@ class Person(collection.CollectionEntity, RestoreEntity): _attr_should_poll = False editable: bool - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Set up person.""" self._config = config - self._latitude = None - self._longitude = None - self._gps_accuracy = None - self._source = None - self._state = None - self._unsub_track_device = None + self._latitude: float | None = None + self._longitude: float | None = None + self._gps_accuracy: float | None = None + self._source: str | None = None + self._state: str | None = None + self._unsub_track_device: Callable[[], None] | None = None @classmethod - def from_storage(cls, config: ConfigType): + def from_storage(cls, config: ConfigType) -> Self: """Return entity instance initialized from storage.""" person = cls(config) person.editable = True return person @classmethod - def from_yaml(cls, config: ConfigType): + def from_yaml(cls, config: ConfigType) -> Self: """Return entity instance initialized from yaml.""" person = cls(config) person.editable = False return person @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._config[CONF_NAME] @@ -436,14 +443,14 @@ class Person(collection.CollectionEntity, RestoreEntity): return self._config.get(CONF_PICTURE) @property - def state(self): + def state(self) -> str | None: """Return the state of the person.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the person.""" - data = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id} + data: dict[str, Any] = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id} if self._latitude is not None: data[ATTR_LATITUDE] = self._latitude if self._longitude is not None: @@ -458,16 +465,16 @@ class Person(collection.CollectionEntity, RestoreEntity): return data @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID for the person.""" return self._config[CONF_ID] @property - def device_trackers(self): + def device_trackers(self) -> list[str]: """Return the device trackers for the person.""" return self._config[CONF_DEVICE_TRACKERS] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register device trackers.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): @@ -479,14 +486,14 @@ class Person(collection.CollectionEntity, RestoreEntity): else: # Wait for hass start to not have race between person # and device trackers finishing setup. - async def person_start_hass(now): + async def person_start_hass(_: Event) -> None: await self.async_update_config(self._config) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, person_start_hass ) - async def async_update_config(self, config: ConfigType): + async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config @@ -504,12 +511,14 @@ class Person(collection.CollectionEntity, RestoreEntity): self._update_state() @callback - def _async_handle_tracker_update(self, event): + def _async_handle_tracker_update( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle the device tracker state changes.""" self._update_state() @callback - def _update_state(self): + def _update_state(self) -> None: """Update the state.""" latest_non_gps_home = latest_not_home = latest_gps = latest = None for entity_id in self._config[CONF_DEVICE_TRACKERS]: @@ -544,7 +553,7 @@ class Person(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def _parse_source_state(self, state): + def _parse_source_state(self, state: State) -> None: """Parse source state and set person attributes. This is a device tracker state or the restored person state. @@ -569,24 +578,8 @@ def ws_list_person( ) -def _get_latest(prev: State | None, curr: State): +def _get_latest(prev: State | None, curr: State) -> State: """Get latest 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/icons.json b/homeassistant/components/person/icons.json new file mode 100644 index 00000000000..fbfd5be75d2 --- /dev/null +++ b/homeassistant/components/person/icons.json @@ -0,0 +1,13 @@ +{ + "entity_component": { + "_": { + "default": "mdi:account", + "state": { + "not_home": "mdi:account-arrow-right" + } + } + }, + "services": { + "reload": "mdi:reload" + } +} diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index b81fec90a59..c8540a187da 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -32,11 +32,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN PLATFORMS = [ - Platform.MEDIA_PLAYER, + Platform.BINARY_SENSOR, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SWITCH, - Platform.BINARY_SENSOR, ] LOGGER = logging.getLogger(__name__) @@ -85,7 +85,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): +class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Coordinator to update data.""" config_entry: ConfigEntry diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index b44d4dd5a62..03b7576703d 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -42,9 +42,7 @@ async def async_register_services(hass: HomeAssistant) -> None: schema=vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, - vol.Exclusive( - ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS - ): cv.positive_int, + vol.Exclusive(ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS): cv.string, vol.Exclusive(ATTR_PRODUCT_NAME, ATTR_PRODUCT_IDENTIFIERS): cv.string, vol.Optional(ATTR_AMOUNT): vol.All(vol.Coerce(int), vol.Range(min=1)), } @@ -63,17 +61,17 @@ async def handle_add_product( hass: HomeAssistant, api_client: PicnicAPI, call: ServiceCall ) -> None: """Handle the call for the add_product service.""" - product_id = call.data.get("product_id") + product_id = call.data.get(ATTR_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[ATTR_PRODUCT_NAME]) ) if not product_id: raise PicnicServiceException("No product found or no product ID given!") await hass.async_add_executor_job( - api_client.add_product, str(product_id), call.data.get("amount", 1) + api_client.add_product, product_id, call.data.get(ATTR_AMOUNT, 1) ) diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index aeb1cea8e15..69c65383138 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -208,7 +208,7 @@ def _device_id(data): return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}" -class PlaatoCoordinator(DataUpdateCoordinator): +class PlaatoCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching data from the API.""" def __init__( diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index c47b91a4adb..6825baff906 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -17,7 +17,7 @@ PLACEHOLDER_DOCS_URL = "docs_url" PLACEHOLDER_DEVICE_TYPE = "device_type" PLACEHOLDER_DEVICE_NAME = "device_name" DOCS_URL = "https://www.home-assistant.io/integrations/plaato/" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] SENSOR_DATA = "sensor_data" COORDINATOR = "coordinator" DEVICE = "device" diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 0c67e20d7ab..c362652cf47 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -28,64 +28,48 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" key: BinarySensorType - icon_off: str | None = None BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="compressor_state", translation_key="compressor_state", - icon="mdi:hvac", - icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", translation_key="cooling_enabled", - icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="dhw_state", translation_key="dhw_state", - icon="mdi:water-pump", - icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="flame_state", translation_key="flame_state", name="Flame state", - icon="mdi:fire", - icon_off="mdi:fire-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="heating_state", translation_key="heating_state", - icon="mdi:radiator", - icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_state", translation_key="cooling_state", - icon="mdi:snowflake", - icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", translation_key="slave_boiler_state", - icon="mdi:fire", - icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", translation_key="plugwise_notification", - icon="mdi:mailbox-up-outline", - icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -140,13 +124,6 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): """Return true if the binary sensor is on.""" return self.device["binary_sensors"][self.entity_description.key] - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if (icon_off := self.entity_description.icon_off) and self.is_on is False: - return icon_off - return self.entity_description.icon - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 84e0619773b..3553df02e8d 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -45,6 +45,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False _previous_mode: str = "heating" @@ -69,6 +70,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if presets := self.device.get("preset_modes"): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index f5677c0b4a9..cad891f16f2 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -44,11 +44,13 @@ NumberType = Literal[ SelectType = Literal[ "select_dhw_mode", + "select_gateway_mode", "select_regulation_mode", "select_schedule", ] SelectOptionsType = Literal[ "dhw_modes", + "gateway_modes", "regulation_modes", "available_schedules", ] diff --git a/homeassistant/components/plugwise/icons.json b/homeassistant/components/plugwise/icons.json new file mode 100644 index 00000000000..4af2c0b4c75 --- /dev/null +++ b/homeassistant/components/plugwise/icons.json @@ -0,0 +1,102 @@ +{ + "entity": { + "binary_sensor": { + "compressor_state": { + "default": "mdi:hvac", + "state": { + "off": "mdi:hvac-off" + } + }, + "cooling_enabled": { + "default": "mdi:snowflake-thermometer" + }, + "cooling_state": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "dhw_state": { + "default": "mdi:water-pump", + "state": { + "off": "mdi:water-pump-off" + } + }, + "flame_state": { + "default": "mdi:fire", + "state": { + "off": "mdi:fire-off" + } + }, + "heating_state": { + "default": "mdi:radiator", + "state": { + "off": "mdi:radiator-off" + } + }, + "plugwise_notification": { + "default": "mdi:mailbox-up-outline", + "state": { + "off": "mdi:mailbox-outline" + } + }, + "slave_boiler_state": { + "default": "mdi:fire", + "state": { + "off": "mdi:circle-off-outline" + } + } + }, + "climate": { + "plugwise": { + "state_attributes": { + "preset_mode": { + "state": { + "asleep": "mdi:weather-night", + "away": "mdi:account-arrow-right", + "home": "mdi:home", + "no_frost": "mdi:snowflake-melt", + "vacation": "mdi:beach" + } + } + } + } + }, + "select": { + "dhw_mode": { + "default": "mdi:shower" + }, + "gateway_mode": { + "default": "mdi:cog-outline" + }, + "regulation_mode": { + "default": "mdi:hvac" + }, + "select_schedule": { + "default": "mdi:calendar-clock" + } + }, + "sensor": { + "gas_consumed_interval": { + "default": "mdi:meter-gas" + }, + "modulation_level": { + "default": "mdi:percent" + }, + "valve_position": { + "default": "mdi:valve" + } + }, + "switch": { + "cooling_ena_switch": { + "default": "mdi:snowflake-thermometer" + }, + "dhw_cm_switch": { + "default": "mdi:water-plus" + }, + "lock": { + "default": "mdi:lock" + } + } + } +} diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 92923e98d2c..9b898305899 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.35.3"], + "requirements": ["plugwise==0.36.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 4be21fe9026..ff5eb3af4a5 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -30,14 +30,12 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_schedule", translation_key="select_schedule", - icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, STATE_ON, opt), options_key="available_schedules", ), PlugwiseSelectEntityDescription( key="select_regulation_mode", translation_key="regulation_mode", - icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), options_key="regulation_modes", @@ -45,11 +43,17 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_dhw_mode", translation_key="dhw_mode", - icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), options_key="dhw_modes", ), + PlugwiseSelectEntityDescription( + key="select_gateway_mode", + translation_key="gateway_mode", + entity_category=EntityCategory.CONFIG, + command=lambda api, loc, opt: api.set_gateway_mode(opt), + options_key="gateway_modes", + ), ) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 95dfc2ba6a3..86992bb08f1 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -315,7 +315,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="gas_consumed_interval", translation_key="gas_consumed_interval", - icon="mdi:meter-gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), @@ -357,7 +356,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="modulation_level", translation_key="modulation_level", - icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -365,7 +363,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="valve_position", translation_key="valve_position", - icon="mdi:valve", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index addd1ceadb1..7d26f5a624c 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -97,6 +97,14 @@ "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } }, + "gateway_mode": { + "name": "Gateway mode", + "state": { + "away": "Pause", + "full": "Normal", + "vacation": "Vacation" + } + }, "regulation_mode": { "name": "Regulation mode", "state": { diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index dfd11127332..50e0a3cc4f8 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -33,13 +33,11 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( PlugwiseSwitchEntityDescription( key="dhw_cm_switch", translation_key="dhw_cm_switch", - icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, ), PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", - icon="mdi:lock", entity_category=EntityCategory.CONFIG, ), PlugwiseSwitchEntityDescription( @@ -51,7 +49,6 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( key="cooling_ena_switch", translation_key="cooling_ena_switch", name="Cooling", - icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, ), ) diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index 241c14f29b9..78c7bf7ff6a 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -3,75 +3,25 @@ import logging from aiohttp import ContentTypeError from requests.exceptions import ConnectTimeout, HTTPError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .utils import load_plum _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [Platform.LIGHT] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Plum Lightpad Platform initialization.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - _LOGGER.debug("Found Plum Lightpad configuration in config, importing") - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Plum Lightpad", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plum Lightpad from a config entry.""" _LOGGER.debug("Setting up config entry with ID = %s", entry.unique_id) diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py index b2afb55fc5d..9f81a57d42e 100644 --- a/homeassistant/components/plum_lightpad/config_flow.py +++ b/homeassistant/components/plum_lightpad/config_flow.py @@ -58,9 +58,3 @@ class PlumLightpadConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password} ) - - async def async_step_import( - self, import_config: dict[str, Any] | None - ) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 8587101a42a..d975537ca61 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -1,17 +1,18 @@ """The Tesla Powerwall integration.""" from __future__ import annotations -import contextlib +import asyncio +from contextlib import AsyncExitStack from datetime import timedelta import logging +from typing import Optional -import requests +from aiohttp import CookieJar from tesla_powerwall import ( AccessDeniedError, - APIError, + ApiError, MissingAttributeError, Powerwall, - PowerwallError, PowerwallUnreachableError, ) @@ -20,17 +21,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, 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 import homeassistant.helpers.config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.network import is_ip_address -from .const import ( - DOMAIN, - POWERWALL_API_CHANGED, - POWERWALL_COORDINATOR, - POWERWALL_HTTP_SESSION, - UPDATE_INTERVAL, -) +from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL from .models import PowerwallBaseInfo, PowerwallData, PowerwallRuntimeData CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -70,11 +67,11 @@ class PowerwallDataManager: """Return true if the api has changed out from under us.""" return self.runtime_data[POWERWALL_API_CHANGED] - def _recreate_powerwall_login(self) -> None: + async def _recreate_powerwall_login(self) -> None: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): - self.power_wall.logout() - self.power_wall.login(self.password or "") + await self.power_wall.logout() + await self.power_wall.login(self.password or "") async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" @@ -82,17 +79,17 @@ class PowerwallDataManager: _LOGGER.debug("Checking if update failed") if self.api_changed: raise UpdateFailed("The powerwall api has changed") - return await self.hass.async_add_executor_job(self._update_data) + return await self._update_data() - def _update_data(self) -> PowerwallData: + async def _update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" _LOGGER.debug("Updating data") for attempt in range(2): try: if attempt == 1: - self._recreate_powerwall_login() - data = _fetch_powerwall_data(self.power_wall) - except PowerwallUnreachableError as err: + await self._recreate_powerwall_login() + data = await _fetch_powerwall_data(self.power_wall) + except (asyncio.TimeoutError, PowerwallUnreachableError) as err: raise UpdateFailed("Unable to fetch data from powerwall") from err except MissingAttributeError as err: _LOGGER.error("The powerwall api has changed: %s", str(err)) @@ -112,7 +109,7 @@ class PowerwallDataManager: _LOGGER.debug("Access denied, trying to reauthenticate") # there is still an attempt left to authenticate, # so we continue in the loop - except APIError as err: + except ApiError as err: raise UpdateFailed(f"Updated failed due to {err}, will retry") from err else: return data @@ -121,42 +118,46 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" - http_session = requests.Session() ip_address: str = entry.data[CONF_IP_ADDRESS] 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( - _login_and_fetch_base_info, power_wall, ip_address, password - ) - except PowerwallUnreachableError as err: - http_session.close() - raise ConfigEntryNotReady from err - except MissingAttributeError as err: - http_session.close() - # The error might include some important information about what exactly changed. - _LOGGER.error("The powerwall api has changed: %s", str(err)) - persistent_notification.async_create( - hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE - ) - return False - except AccessDeniedError as err: - _LOGGER.debug("Authentication failed", exc_info=err) - http_session.close() - raise ConfigEntryAuthFailed from err - except APIError as err: - http_session.close() - raise ConfigEntryNotReady from err + http_session = async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) + + async with AsyncExitStack() as stack: + power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) + stack.push_async_callback(power_wall.close) + + try: + base_info = await _login_and_fetch_base_info( + power_wall, ip_address, password + ) + + # Cancel closing power_wall on success + stack.pop_all() + except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + raise ConfigEntryNotReady from err + except MissingAttributeError as err: + # The error might include some important information about what exactly changed. + _LOGGER.error("The powerwall api has changed: %s", str(err)) + persistent_notification.async_create( + hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE + ) + return False + except AccessDeniedError as err: + _LOGGER.debug("Authentication failed", exc_info=err) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din - if gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id): + if entry.unique_id is not None and is_ip_address(entry.unique_id): hass.config_entries.async_update_entry(entry, unique_id=gateway_din) runtime_data = PowerwallRuntimeData( api_changed=False, base_info=base_info, - http_session=http_session, coordinator=None, api_instance=power_wall, ) @@ -178,50 +179,118 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data + await async_migrate_entity_unique_ids(hass, entry, base_info) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -def _login_and_fetch_base_info( +async def async_migrate_entity_unique_ids( + hass: HomeAssistant, entry: ConfigEntry, base_info: PowerwallBaseInfo +) -> None: + """Migrate old entity unique ids to use gateway_din.""" + old_base_unique_id = "_".join(base_info.serial_numbers) + new_base_unique_id = base_info.gateway_din + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device(identifiers={(DOMAIN, old_base_unique_id)}): + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, new_base_unique_id)} + ) + + ent_reg = er.async_get(hass) + for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + current_unique_id = ent_entry.unique_id + if current_unique_id.startswith(old_base_unique_id): + unique_id_postfix = current_unique_id.removeprefix(old_base_unique_id) + new_unique_id = f"{new_base_unique_id}{unique_id_postfix}" + ent_reg.async_update_entity( + ent_entry.entity_id, new_unique_id=new_unique_id + ) + + +async def _login_and_fetch_base_info( power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: - power_wall.login(password) - return call_base_info(power_wall, host) + await power_wall.login(password) + return await _call_base_info(power_wall, host) -def call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: +async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: """Return PowerwallBaseInfo for the device.""" - # Make sure the serial numbers always have the same order - gateway_din = None - with contextlib.suppress(AssertionError, PowerwallError): - gateway_din = power_wall.get_gateway_din().upper() + + try: + async with asyncio.TaskGroup() as tg: + gateway_din = tg.create_task(power_wall.get_gateway_din()) + site_info = tg.create_task(power_wall.get_site_info()) + status = tg.create_task(power_wall.get_status()) + device_type = tg.create_task(power_wall.get_device_type()) + serial_numbers = tg.create_task(power_wall.get_serial_numbers()) + batteries = tg.create_task(power_wall.get_batteries()) + + # Mimic the behavior of asyncio.gather by reraising the first caught exception since + # this is what is expected by the caller of this method + # + # While it would have been cleaner to use asyncio.gather in the first place instead of + # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to + # missing typing information. + except BaseExceptionGroup as e: + raise e.exceptions[0] from None + + # Serial numbers MUST be sorted to ensure the unique_id is always the same + # for backwards compatibility. return PowerwallBaseInfo( - gateway_din=gateway_din, - site_info=power_wall.get_site_info(), - status=power_wall.get_status(), - device_type=power_wall.get_device_type(), - serial_numbers=sorted(power_wall.get_serial_numbers()), + gateway_din=gateway_din.result().upper(), + site_info=site_info.result(), + status=status.result(), + device_type=device_type.result(), + serial_numbers=sorted(serial_numbers.result()), url=f"https://{host}", + batteries={battery.serial_number: battery for battery in batteries.result()}, ) -def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: - """Process and update powerwall data.""" +async def get_backup_reserve_percentage(power_wall: Powerwall) -> Optional[float]: + """Return the backup reserve percentage.""" try: - backup_reserve = power_wall.get_backup_reserve_percentage() + return await power_wall.get_backup_reserve_percentage() except MissingAttributeError: - backup_reserve = None + return None + + +async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: + """Process and update powerwall data.""" + + try: + async with asyncio.TaskGroup() as tg: + backup_reserve = tg.create_task(get_backup_reserve_percentage(power_wall)) + charge = tg.create_task(power_wall.get_charge()) + site_master = tg.create_task(power_wall.get_sitemaster()) + meters = tg.create_task(power_wall.get_meters()) + grid_services_active = tg.create_task(power_wall.is_grid_services_active()) + grid_status = tg.create_task(power_wall.get_grid_status()) + batteries = tg.create_task(power_wall.get_batteries()) + + # Mimic the behavior of asyncio.gather by reraising the first caught exception since + # this is what is expected by the caller of this method + # + # While it would have been cleaner to use asyncio.gather in the first place instead of + # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to + # missing typing information. + except BaseExceptionGroup as e: + raise e.exceptions[0] from None return PowerwallData( - charge=power_wall.get_charge(), - site_master=power_wall.get_sitemaster(), - meters=power_wall.get_meters(), - grid_services_active=power_wall.is_grid_services_active(), - grid_status=power_wall.get_grid_status(), - backup_reserve=backup_reserve, + charge=charge.result(), + site_master=site_master.result(), + meters=meters.result(), + grid_services_active=grid_services_active.result(), + grid_status=grid_status.result(), + backup_reserve=backup_reserve.result(), + batteries={battery.serial_number: battery for battery in batteries.result()}, ) @@ -240,8 +309,6 @@ 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) - hass.data[DOMAIN][entry.entry_id][POWERWALL_HTTP_SESSION].close() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 084ec0ea8a6..b73068985d5 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -1,5 +1,7 @@ """Support for powerwall binary sensors.""" +from typing import TYPE_CHECKING + from tesla_powerwall import GridStatus, MeterType from homeassistant.components.binary_sensor import ( @@ -131,5 +133,9 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Powerwall is charging.""" + meter = self.data.meters.get_meter(MeterType.BATTERY) + # Meter cannot be None because of the available property + if TYPE_CHECKING: + assert meter is not None # is_sending_to returns true for values greater than 100 watts - return self.data.meters.get_meter(MeterType.BATTERY).is_sending_to() + return meter.is_sending_to() diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index f4ebc0f33b1..e86949e2227 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,16 +1,18 @@ """Config flow for Tesla Powerwall integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any +from aiohttp import CookieJar from tesla_powerwall import ( AccessDeniedError, MissingAttributeError, Powerwall, PowerwallUnreachableError, - SiteInfo, + SiteInfoResponse, ) import voluptuous as vol @@ -18,6 +20,7 @@ from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful @@ -32,19 +35,23 @@ ENTRY_FAILURE_STATES = { } -def _login_and_fetch_site_info( +async def _login_and_fetch_site_info( power_wall: Powerwall, password: str -) -> tuple[SiteInfo, str]: +) -> tuple[SiteInfoResponse, str]: """Login to the powerwall and fetch the base info.""" if password is not None: - power_wall.login(password) - return power_wall.get_site_info(), power_wall.get_gateway_din() + await power_wall.login(password) + + return await asyncio.gather( + power_wall.get_site_info(), power_wall.get_gateway_din() + ) -def _powerwall_is_reachable(ip_address: str, password: str) -> bool: +async def _powerwall_is_reachable(ip_address: str, password: str) -> bool: """Check if the powerwall is reachable.""" try: - Powerwall(ip_address).login(password) + async with Powerwall(ip_address) as power_wall: + await power_wall.login(password) except AccessDeniedError: return True except PowerwallUnreachableError: @@ -59,21 +66,23 @@ async def validate_input( Data has the keys from schema with values provided by the user. """ + session = async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) + async with Powerwall(data[CONF_IP_ADDRESS], http_session=session) as power_wall: + password = data[CONF_PASSWORD] - power_wall = Powerwall(data[CONF_IP_ADDRESS]) - password = data[CONF_PASSWORD] + try: + site_info, gateway_din = await _login_and_fetch_site_info( + power_wall, password + ) + except MissingAttributeError as err: + # Only log the exception without the traceback + _LOGGER.error(str(err)) + raise WrongVersion from err - try: - site_info, gateway_din = await hass.async_add_executor_job( - _login_and_fetch_site_info, power_wall, password - ) - except MissingAttributeError as err: - # Only log the exception without the traceback - _LOGGER.error(str(err)) - raise WrongVersion from err - - # Return info that you want to store in the config entry. - return {"title": site_info.site_name, "unique_id": gateway_din.upper()} + # Return info that you want to store in the config entry. + return {"title": site_info.site_name, "unique_id": gateway_din.upper()} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -102,9 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return bool( entry.state in ENTRY_FAILURE_STATES or not async_last_update_was_successful(self.hass, entry) - ) and not await self.hass.async_add_executor_job( - _powerwall_is_reachable, ip_address, password - ) + ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" @@ -137,7 +144,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "name": gateway_din, "ip_address": self.ip_address, } - errors, info = await self._async_try_connect( + errors, info, _ = await self._async_try_connect( {CONF_IP_ADDRESS: self.ip_address, CONF_PASSWORD: gateway_din[-5:]} ) if errors: @@ -152,23 +159,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, user_input: dict[str, Any] - ) -> tuple[dict[str, Any] | None, dict[str, str] | None]: + ) -> tuple[dict[str, Any] | None, dict[str, str] | None, dict[str, str]]: """Try to connect to the powerwall.""" info = None errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) - except PowerwallUnreachableError: + except (PowerwallUnreachableError, asyncio.TimeoutError) as ex: errors[CONF_IP_ADDRESS] = "cannot_connect" - except WrongVersion: + description_placeholders = {"error": str(ex)} + except WrongVersion as ex: errors["base"] = "wrong_version" - except AccessDeniedError: + description_placeholders = {"error": str(ex)} + except AccessDeniedError as ex: errors[CONF_PASSWORD] = "invalid_auth" - except Exception: # pylint: disable=broad-except + description_placeholders = {"error": str(ex)} + except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + description_placeholders = {"error": str(ex)} - return errors, info + return errors, info, description_placeholders async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -204,8 +216,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] = {} if user_input is not None: - errors, info = await self._async_try_connect(user_input) + errors, info, description_placeholders = await self._async_try_connect( + user_input + ) if not errors: assert info is not None if info["unique_id"]: @@ -227,6 +242,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_reauth_confirm( @@ -235,22 +251,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle reauth confirmation.""" assert self.reauth_entry is not None errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] = {} if user_input is not None: entry_data = self.reauth_entry.data - errors, _ = await self._async_try_connect( + errors, _, description_placeholders = await self._async_try_connect( {CONF_IP_ADDRESS: entry_data[CONF_IP_ADDRESS], **user_input} ) if not errors: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.reauth_entry, data={**entry_data, **user_input} ) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index b22e6466cf6..c20ab760f23 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -7,7 +7,6 @@ POWERWALL_BASE_INFO: Final = "base_info" POWERWALL_COORDINATOR: Final = "coordinator" POWERWALL_API: Final = "api_instance" POWERWALL_API_CHANGED: Final = "api_changed" -POWERWALL_HTTP_SESSION: Final = "http_session" UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index f0cfec2cbc5..cad371ea42c 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -14,7 +14,7 @@ from .const import ( POWERWALL_BASE_INFO, POWERWALL_COORDINATOR, ) -from .models import PowerwallData, PowerwallRuntimeData +from .models import BatteryResponse, PowerwallData, PowerwallRuntimeData class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): @@ -29,8 +29,7 @@ class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): assert coordinator is not None super().__init__(coordinator) self.power_wall = powerwall_data[POWERWALL_API] - # The serial numbers of the powerwalls are unique to every site - self.base_unique_id = "_".join(base_info.serial_numbers) + self.base_unique_id = base_info.gateway_din self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.base_unique_id)}, manufacturer=MANUFACTURER, @@ -44,3 +43,36 @@ class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): def data(self) -> PowerwallData: """Return the coordinator data.""" return self.coordinator.data + + +class BatteryEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): + """Base class for battery entities.""" + + _attr_has_entity_name = True + + def __init__( + self, powerwall_data: PowerwallRuntimeData, battery: BatteryResponse + ) -> None: + """Initialize the entity.""" + base_info = powerwall_data[POWERWALL_BASE_INFO] + coordinator = powerwall_data[POWERWALL_COORDINATOR] + assert coordinator is not None + super().__init__(coordinator) + self.serial_number = battery.serial_number + self.power_wall = powerwall_data[POWERWALL_API] + self.base_unique_id = f"{base_info.gateway_din}_{battery.serial_number}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.base_unique_id)}, + manufacturer=MANUFACTURER, + model=f"{MODEL} ({battery.part_number})", + name=f"{base_info.site_info.site_name} {battery.serial_number}", + sw_version=base_info.status.version, + configuration_url=base_info.url, + via_device=(DOMAIN, base_info.gateway_din), + ) + + @property + def battery_data(self) -> BatteryResponse: + """Return the coordinator data.""" + return self.coordinator.data.batteries[self.serial_number] diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 989940e9f1d..4185e90ab7b 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/powerwall", "iot_class": "local_polling", "loggers": ["tesla_powerwall"], - "requirements": ["tesla-powerwall==0.3.19"] + "requirements": ["tesla-powerwall==0.5.1"] } diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/models.py index 3ee95b815f5..3216b83a7db 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/models.py @@ -4,15 +4,15 @@ from __future__ import annotations from dataclasses import dataclass from typing import TypedDict -from requests import Session from tesla_powerwall import ( + BatteryResponse, DeviceType, GridStatus, - MetersAggregates, + MetersAggregatesResponse, Powerwall, - PowerwallStatus, - SiteInfo, - SiteMaster, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,12 +22,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator class PowerwallBaseInfo: """Base information for the powerwall integration.""" - gateway_din: None | str - site_info: SiteInfo - status: PowerwallStatus + gateway_din: str + site_info: SiteInfoResponse + status: PowerwallStatusResponse device_type: DeviceType serial_numbers: list[str] url: str + batteries: dict[str, BatteryResponse] @dataclass @@ -35,11 +36,12 @@ class PowerwallData: """Point in time data for the powerwall integration.""" charge: float - site_master: SiteMaster - meters: MetersAggregates + site_master: SiteMasterResponse + meters: MetersAggregatesResponse grid_services_active: bool grid_status: GridStatus backup_reserve: float | None + batteries: dict[str, BatteryResponse] class PowerwallRuntimeData(TypedDict): @@ -49,4 +51,3 @@ class PowerwallRuntimeData(TypedDict): api_instance: Powerwall base_info: PowerwallBaseInfo api_changed: bool - http_session: Session diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index bfa75392efb..9e17cd32e9c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -3,8 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING, Generic, TypeVar -from tesla_powerwall import Meter, MeterType +from tesla_powerwall import GridState, MeterResponse, MeterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -22,52 +24,58 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, POWERWALL_COORDINATOR -from .entity import PowerWallEntity -from .models import PowerwallRuntimeData +from .entity import BatteryEntity, PowerWallEntity +from .models import BatteryResponse, PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" +_ValueParamT = TypeVar("_ValueParamT") +_ValueT = TypeVar("_ValueT", bound=float | int | str) + @dataclass(frozen=True) -class PowerwallRequiredKeysMixin: +class PowerwallRequiredKeysMixin(Generic[_ValueParamT, _ValueT]): """Mixin for required keys.""" - value_fn: Callable[[Meter], float] + value_fn: Callable[[_ValueParamT], _ValueT] @dataclass(frozen=True) class PowerwallSensorEntityDescription( - SensorEntityDescription, PowerwallRequiredKeysMixin + SensorEntityDescription, + PowerwallRequiredKeysMixin[_ValueParamT, _ValueT], + Generic[_ValueParamT, _ValueT], ): """Describes Powerwall entity.""" -def _get_meter_power(meter: Meter) -> float: +def _get_meter_power(meter: MeterResponse) -> float: """Get the current value in kW.""" return meter.get_power(precision=3) -def _get_meter_frequency(meter: Meter) -> float: +def _get_meter_frequency(meter: MeterResponse) -> float: """Get the current value in Hz.""" return round(meter.frequency, 1) -def _get_meter_total_current(meter: Meter) -> float: +def _get_meter_total_current(meter: MeterResponse) -> float: """Get the current value in A.""" return meter.get_instant_total_current() -def _get_meter_average_voltage(meter: Meter) -> float: +def _get_meter_average_voltage(meter: MeterResponse) -> float: """Get the current value in V.""" - return round(meter.average_voltage, 1) + return round(meter.instant_average_voltage, 1) POWERWALL_INSTANT_SENSORS = ( - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_power", translation_key="instant_power", state_class=SensorStateClass.MEASUREMENT, @@ -75,7 +83,7 @@ POWERWALL_INSTANT_SENSORS = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, value_fn=_get_meter_power, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_frequency", translation_key="instant_frequency", state_class=SensorStateClass.MEASUREMENT, @@ -84,7 +92,7 @@ POWERWALL_INSTANT_SENSORS = ( entity_registry_enabled_default=False, value_fn=_get_meter_frequency, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_current", translation_key="instant_current", state_class=SensorStateClass.MEASUREMENT, @@ -93,7 +101,7 @@ POWERWALL_INSTANT_SENSORS = ( entity_registry_enabled_default=False, value_fn=_get_meter_total_current, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_voltage", translation_key="instant_voltage", state_class=SensorStateClass.MEASUREMENT, @@ -105,6 +113,100 @@ POWERWALL_INSTANT_SENSORS = ( ) +BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="battery_capacity", + translation_key="battery_capacity", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda battery_data: battery_data.capacity, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_instant_voltage", + translation_key="battery_instant_voltage", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda battery_data: round(battery_data.v_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="instant_frequency", + translation_key="instant_frequency", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + entity_registry_enabled_default=False, + value_fn=lambda battery_data: round(battery_data.f_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="instant_current", + translation_key="instant_current", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + value_fn=lambda battery_data: round(battery_data.i_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="instant_power", + translation_key="instant_power", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda battery_data: battery_data.p_out, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_export", + translation_key="battery_export", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=0, + value_fn=lambda battery_data: battery_data.energy_discharged, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_import", + translation_key="battery_import", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=0, + value_fn=lambda battery_data: battery_data.energy_charged, + ), + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="battery_remaining", + translation_key="battery_remaining", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda battery_data: battery_data.energy_remaining, + ), + PowerwallSensorEntityDescription[BatteryResponse, str]( + key="grid_state", + translation_key="grid_state", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[state.value.lower() for state in GridState], + value_fn=lambda battery_data: battery_data.grid_state.value.lower(), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -115,7 +217,7 @@ async def async_setup_entry( coordinator = powerwall_data[POWERWALL_COORDINATOR] assert coordinator is not None data = coordinator.data - entities: list[PowerWallEntity] = [ + entities: list[Entity] = [ PowerWallChargeSensor(powerwall_data), ] @@ -130,6 +232,12 @@ async def async_setup_entry( for description in POWERWALL_INSTANT_SENSORS ) + for battery in data.batteries.values(): + entities.extend( + PowerWallBatterySensor(powerwall_data, battery, description) + for description in BATTERY_INSTANT_SENSORS + ) + async_add_entities(entities) @@ -155,13 +263,13 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" - entity_description: PowerwallSensorEntityDescription + entity_description: PowerwallSensorEntityDescription[MeterResponse, float] def __init__( self, powerwall_data: PowerwallRuntimeData, meter: MeterType, - description: PowerwallSensorEntityDescription, + description: PowerwallSensorEntityDescription[MeterResponse, float], ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -171,9 +279,13 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{description.key}" @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value.""" - return self.entity_description.value_fn(self.data.meters.get_meter(self._meter)) + meter = self.data.meters.get_meter(self._meter) + if meter is not None: + return self.entity_description.value_fn(meter) + + return None class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): @@ -224,10 +336,10 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): we do not want to include in statistics and its a transient data error. """ - return super().available and self.native_value != 0 + return super().available and self.meter is not None @property - def meter(self) -> Meter: + def meter(self) -> MeterResponse | None: """Get the meter for the sensor.""" return self.data.meters.get_meter(self._meter) @@ -244,9 +356,12 @@ class PowerWallExportSensor(PowerWallEnergyDirectionSensor): super().__init__(powerwall_data, meter, _METER_DIRECTION_EXPORT) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value in kWh.""" - return self.meter.get_energy_exported() + meter = self.meter + if TYPE_CHECKING: + assert meter is not None + return meter.get_energy_exported() class PowerWallImportSensor(PowerWallEnergyDirectionSensor): @@ -261,6 +376,32 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor): super().__init__(powerwall_data, meter, _METER_DIRECTION_IMPORT) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value in kWh.""" - return self.meter.get_energy_imported() + meter = self.meter + if TYPE_CHECKING: + assert meter is not None + return meter.get_energy_imported() + + +class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]): + """Representation of an Powerwall Battery sensor.""" + + entity_description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT] + + def __init__( + self, + powerwall_data: PowerwallRuntimeData, + battery: BatteryResponse, + description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT], + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(powerwall_data, battery) + self._attr_translation_key = description.translation_key + self._attr_unique_id = f"{self.base_unique_id}_{description.key}" + + @property + def native_value(self) -> float | int | str: + """Get the current value.""" + return self.entity_description.value_fn(self.battery_data) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 8be76dc8716..8e18dfb308d 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -23,10 +23,10 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", - "unknown": "[%key:common::config_flow::error::unknown%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "cannot_connect": "A connection error occurred while connecting to the Powerwall: {error}", + "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved: {error}", + "unknown": "An unknown error occurred: {error}", + "invalid_auth": "Authentication failed with error: {error}" }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -146,6 +146,20 @@ "battery_export": { "name": "Battery export" }, + "battery_capacity": { + "name": "Battery capacity" + }, + "battery_remaining": { + "name": "Battery remaining" + }, + "grid_state": { + "name": "Grid state", + "state": { + "grid_compliant": "Compliant", + "grid_qualifying": "Qualifying", + "grid_uncompliant": "Uncompliant" + } + }, "load_import": { "name": "Load import" }, diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 8516890d633..673672915fa 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -59,9 +59,7 @@ class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity): async def _async_set_island_mode(self, island_mode: IslandMode) -> None: """Toggles off-grid mode using the island_mode argument.""" try: - await self.hass.async_add_executor_job( - self.power_wall.set_island_mode, island_mode - ) + await self.power_wall.set_island_mode(island_mode) except PowerwallError as ex: raise HomeAssistantError( f"Setting off-grid operation to {island_mode} failed: {ex}" diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index d1d27b78769..1bf23befbdb 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 5f841441d59..797fd751197 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -60,6 +60,7 @@ class ProliphixThermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, pdp): """Initialize the thermostat.""" diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 7beac4cc54b..e17ae1190a4 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -1,10 +1,15 @@ """Support for Prometheus metrics export.""" +from __future__ import annotations + +from collections.abc import Callable from contextlib import suppress import logging import string +from typing import Any, TypeVar, cast from aiohttp import web import prometheus_client +from prometheus_client.metrics import MetricWrapperBase import voluptuous as vol from homeassistant import core as hacore @@ -40,15 +45,20 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entityfilter, state as state_helper import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.entity_registry import ( + EVENT_ENTITY_REGISTRY_UPDATED, + EventEntityRegistryUpdatedData, +) from homeassistant.helpers.entity_values import EntityValues -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter +_MetricBaseT = TypeVar("_MetricBaseT", bound=MetricWrapperBase) _LOGGER = logging.getLogger(__name__) API_ENDPOINT = "/api/prometheus" @@ -95,16 +105,14 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Prometheus component.""" - hass.http.register_view( - PrometheusView(prometheus_client, config[DOMAIN][CONF_REQUIRES_AUTH]) - ) + hass.http.register_view(PrometheusView(config[DOMAIN][CONF_REQUIRES_AUTH])) - conf = config[DOMAIN] - entity_filter = conf[CONF_FILTER] - namespace = conf.get(CONF_PROM_NAMESPACE) + conf: dict[str, Any] = config[DOMAIN] + entity_filter: entityfilter.EntityFilter = conf[CONF_FILTER] + namespace: str = conf[CONF_PROM_NAMESPACE] climate_units = hass.config.units.temperature_unit - override_metric = conf.get(CONF_OVERRIDE_METRIC) - default_metric = conf.get(CONF_DEFAULT_METRIC) + override_metric: str | None = conf.get(CONF_OVERRIDE_METRIC) + default_metric: str | None = conf.get(CONF_DEFAULT_METRIC) component_config = EntityValues( conf[CONF_COMPONENT_CONFIG], conf[CONF_COMPONENT_CONFIG_DOMAIN], @@ -112,7 +120,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) metrics = PrometheusMetrics( - prometheus_client, entity_filter, namespace, climate_units, @@ -121,9 +128,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) # type: ignore[arg-type] hass.bus.listen( - EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated + EVENT_ENTITY_REGISTRY_UPDATED, + metrics.handle_entity_registry_updated, # type: ignore[arg-type] ) for state in hass.states.all(): @@ -138,21 +146,21 @@ class PrometheusMetrics: def __init__( self, - prometheus_cli, - entity_filter, - namespace, - climate_units, - component_config, - override_metric, - default_metric, - ): + entity_filter: entityfilter.EntityFilter, + namespace: str, + climate_units: UnitOfTemperature, + component_config: EntityValues, + override_metric: str | None, + default_metric: str | None, + ) -> None: """Initialize Prometheus Metrics.""" - self.prometheus_cli = prometheus_cli self._component_config = component_config self._override_metric = override_metric self._default_metric = default_metric self._filter = entity_filter - self._sensor_metric_handlers = [ + self._sensor_metric_handlers: list[ + Callable[[State, str | None], str | None] + ] = [ self._sensor_override_component_metric, self._sensor_override_metric, self._sensor_timestamp_metric, @@ -165,10 +173,12 @@ class PrometheusMetrics: self.metrics_prefix = f"{namespace}_" else: self.metrics_prefix = "" - self._metrics = {} + self._metrics: dict[str, MetricWrapperBase] = {} self._climate_units = climate_units - def handle_state_changed_event(self, event): + def handle_state_changed_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return @@ -184,7 +194,7 @@ class PrometheusMetrics: self.handle_state(state) - def handle_state(self, state): + def handle_state(self, state: State) -> None: """Add/update a state in Prometheus.""" entity_id = state.entity_id _LOGGER.debug("Handling state update for %s", entity_id) @@ -199,38 +209,40 @@ class PrometheusMetrics: labels = self._labels(state) state_change = self._metric( - "state_change", self.prometheus_cli.Counter, "The number of state changes" + "state_change", prometheus_client.Counter, "The number of state changes" ) state_change.labels(**labels).inc() entity_available = self._metric( "entity_available", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Entity is available (not in the unavailable or unknown state)", ) entity_available.labels(**labels).set(float(state.state not in ignored_states)) last_updated_time_seconds = self._metric( "last_updated_time_seconds", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "The last_updated timestamp", ) last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) - def handle_entity_registry_updated(self, event): + def handle_entity_registry_updated( + self, event: EventType[EventEntityRegistryUpdatedData] + ) -> None: """Listen for deleted, disabled or renamed entities and remove them from the Prometheus Registry.""" - if (action := event.data.get("action")) in (None, "create"): + if event.data["action"] in (None, "create"): return entity_id = event.data.get("entity_id") _LOGGER.debug("Handling entity update for %s", entity_id) - metrics_entity_id = None + metrics_entity_id: str | None = None - if action == "remove": + if event.data["action"] == "remove": metrics_entity_id = entity_id - elif action == "update": - changes = event.data.get("changes") + elif event.data["action"] == "update": + changes = event.data["changes"] if "entity_id" in changes: metrics_entity_id = changes["entity_id"] @@ -240,10 +252,14 @@ class PrometheusMetrics: if metrics_entity_id: self._remove_labelsets(metrics_entity_id) - def _remove_labelsets(self, entity_id, friendly_name=None): + def _remove_labelsets( + self, entity_id: str, friendly_name: str | None = None + ) -> None: """Remove labelsets matching the given entity id from all metrics.""" for _, metric in self._metrics.items(): - for sample in metric.collect()[0].samples: + for sample in cast(list[prometheus_client.Metric], metric.collect())[ + 0 + ].samples: if sample.labels["entity"] == entity_id and ( not friendly_name or sample.labels["friendly_name"] == friendly_name ): @@ -255,11 +271,11 @@ class PrometheusMetrics: with suppress(KeyError): metric.remove(*sample.labels.values()) - def _handle_attributes(self, state): + def _handle_attributes(self, state: State) -> None: for key, value in state.attributes.items(): metric = self._metric( f"{state.domain}_attr_{key.lower()}", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"{key} attribute of {state.domain} entity", ) @@ -269,13 +285,19 @@ class PrometheusMetrics: except (ValueError, TypeError): pass - def _metric(self, metric, factory, documentation, extra_labels=None): + def _metric( + self, + metric: str, + factory: type[_MetricBaseT], + documentation: str, + extra_labels: list[str] | None = None, + ) -> _MetricBaseT: labels = ["entity", "friendly_name", "domain"] if extra_labels is not None: labels.extend(extra_labels) try: - return self._metrics[metric] + return cast(_MetricBaseT, self._metrics[metric]) except KeyError: full_metric_name = self._sanitize_metric_name( f"{self.metrics_prefix}{metric}" @@ -284,9 +306,9 @@ class PrometheusMetrics: full_metric_name, documentation, labels, - registry=self.prometheus_cli.REGISTRY, + registry=prometheus_client.REGISTRY, ) - return self._metrics[metric] + return cast(_MetricBaseT, self._metrics[metric]) @staticmethod def _sanitize_metric_name(metric: str) -> str: @@ -303,7 +325,7 @@ class PrometheusMetrics: ) @staticmethod - def state_as_number(state): + def state_as_number(state: State) -> float: """Return a state casted to a float.""" try: if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: @@ -316,18 +338,18 @@ class PrometheusMetrics: return value @staticmethod - def _labels(state): + def _labels(state: State) -> dict[str, Any]: return { "entity": state.entity_id, "domain": state.domain, "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), } - def _battery(self, state): + def _battery(self, state: State) -> None: if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Battery level as a percentage of its capacity", ) try: @@ -336,35 +358,35 @@ class PrometheusMetrics: except ValueError: pass - def _handle_binary_sensor(self, state): + def _handle_binary_sensor(self, state: State) -> None: metric = self._metric( "binary_sensor_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the binary sensor (0/1)", ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_input_boolean(self, state): + def _handle_input_boolean(self, state: State) -> None: metric = self._metric( "input_boolean_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the input boolean (0/1)", ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _numeric_handler(self, state, domain, title): + def _numeric_handler(self, state: State, domain: str, title: str) -> None: if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( f"{domain}_state_{unit}", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"State of the {title} measured in {unit}", ) else: metric = self._metric( f"{domain}_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"State of the {title}", ) @@ -379,32 +401,32 @@ class PrometheusMetrics: ) metric.labels(**self._labels(state)).set(value) - def _handle_input_number(self, state): + def _handle_input_number(self, state: State) -> None: self._numeric_handler(state, "input_number", "input number") - def _handle_number(self, state): + def _handle_number(self, state: State) -> None: self._numeric_handler(state, "number", "number") - def _handle_device_tracker(self, state): + def _handle_device_tracker(self, state: State) -> None: metric = self._metric( "device_tracker_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the device tracker (0/1)", ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_person(self, state): + def _handle_person(self, state: State) -> None: metric = self._metric( - "person_state", self.prometheus_cli.Gauge, "State of the person (0/1)" + "person_state", prometheus_client.Gauge, "State of the person (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_cover(self, state): + def _handle_cover(self, state: State) -> None: metric = self._metric( "cover_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the cover (0/1)", ["state"], ) @@ -419,7 +441,7 @@ class PrometheusMetrics: if position is not None: position_metric = self._metric( "cover_position", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Position of the cover (0-100)", ) position_metric.labels(**self._labels(state)).set(float(position)) @@ -428,15 +450,15 @@ class PrometheusMetrics: if tilt_position is not None: tilt_position_metric = self._metric( "cover_tilt_position", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Tilt Position of the cover (0-100)", ) tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position)) - def _handle_light(self, state): + def _handle_light(self, state: State) -> None: metric = self._metric( "light_brightness_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Light brightness percentage (0..100)", ) @@ -451,14 +473,16 @@ class PrometheusMetrics: except ValueError: pass - def _handle_lock(self, state): + def _handle_lock(self, state: State) -> None: metric = self._metric( - "lock_state", self.prometheus_cli.Gauge, "State of the lock (0/1)" + "lock_state", prometheus_client.Gauge, "State of the lock (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_climate_temp(self, state, attr, metric_name, metric_description): + def _handle_climate_temp( + self, state: State, attr: str, metric_name: str, metric_description: str + ) -> None: if (temp := state.attributes.get(attr)) is not None: if self._climate_units == UnitOfTemperature.FAHRENHEIT: temp = TemperatureConverter.convert( @@ -466,12 +490,12 @@ class PrometheusMetrics: ) metric = self._metric( metric_name, - self.prometheus_cli.Gauge, + prometheus_client.Gauge, metric_description, ) metric.labels(**self._labels(state)).set(temp) - def _handle_climate(self, state): + def _handle_climate(self, state: State) -> None: self._handle_climate_temp( state, ATTR_TEMPERATURE, @@ -500,7 +524,7 @@ class PrometheusMetrics: if current_action := state.attributes.get(ATTR_HVAC_ACTION): metric = self._metric( "climate_action", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "HVAC action", ["action"], ) @@ -514,7 +538,7 @@ class PrometheusMetrics: if current_mode and available_modes: metric = self._metric( "climate_mode", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "HVAC mode", ["mode"], ) @@ -523,19 +547,19 @@ class PrometheusMetrics: float(mode == current_mode) ) - def _handle_humidifier(self, state): + def _handle_humidifier(self, state: State) -> None: humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY) if humidifier_target_humidity_percent: metric = self._metric( "humidifier_target_humidity_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Target Relative Humidity", ) metric.labels(**self._labels(state)).set(humidifier_target_humidity_percent) metric = self._metric( "humidifier_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the humidifier (0/1)", ) try: @@ -549,7 +573,7 @@ class PrometheusMetrics: if current_mode and available_modes: metric = self._metric( "humidifier_mode", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Humidifier Mode", ["mode"], ) @@ -558,7 +582,7 @@ class PrometheusMetrics: float(mode == current_mode) ) - def _handle_sensor(self, state): + def _handle_sensor(self, state: State) -> None: unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) for metric_handler in self._sensor_metric_handlers: @@ -571,7 +595,7 @@ class PrometheusMetrics: if unit: documentation = f"Sensor data measured in {unit}" - _metric = self._metric(metric, self.prometheus_cli.Gauge, documentation) + _metric = self._metric(metric, prometheus_client.Gauge, documentation) try: value = self.state_as_number(state) @@ -588,12 +612,12 @@ class PrometheusMetrics: self._battery(state) - def _sensor_default_metric(self, state, unit): + def _sensor_default_metric(self, state: State, unit: str | None) -> str | None: """Get default metric.""" return self._default_metric @staticmethod - def _sensor_attribute_metric(state, unit): + def _sensor_attribute_metric(state: State, unit: str | None) -> str | None: """Get metric based on device class attribute.""" metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric is not None: @@ -601,25 +625,27 @@ class PrometheusMetrics: return None @staticmethod - def _sensor_timestamp_metric(state, unit): + def _sensor_timestamp_metric(state: State, unit: str | None) -> str | None: """Get metric for timestamp sensors, which have no unit of measurement attribute.""" metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" return None - def _sensor_override_metric(self, state, unit): + def _sensor_override_metric(self, state: State, unit: str | None) -> str | None: """Get metric from override in configuration.""" if self._override_metric: return self._override_metric return None - def _sensor_override_component_metric(self, state, unit): + def _sensor_override_component_metric( + self, state: State, unit: str | None + ) -> str | None: """Get metric from override in component confioguration.""" return self._component_config.get(state.entity_id).get(CONF_OVERRIDE_METRIC) @staticmethod - def _sensor_fallback_metric(state, unit): + def _sensor_fallback_metric(state: State, unit: str | None) -> str | None: """Get metric from fallback logic for compatibility.""" if unit in (None, ""): try: @@ -631,10 +657,10 @@ class PrometheusMetrics: return f"sensor_unit_{unit}" @staticmethod - def _unit_string(unit): + def _unit_string(unit: str | None) -> str | None: """Get a formatted string of the unit.""" if unit is None: - return + return None units = { UnitOfTemperature.CELSIUS: "celsius", @@ -645,9 +671,9 @@ class PrometheusMetrics: default = default.lower() return units.get(unit, default) - def _handle_switch(self, state): + def _handle_switch(self, state: State) -> None: metric = self._metric( - "switch_state", self.prometheus_cli.Gauge, "State of the switch (0/1)" + "switch_state", prometheus_client.Gauge, "State of the switch (0/1)" ) try: @@ -658,31 +684,31 @@ class PrometheusMetrics: self._handle_attributes(state) - def _handle_zwave(self, state): + def _handle_zwave(self, state: State) -> None: self._battery(state) - def _handle_automation(self, state): + def _handle_automation(self, state: State) -> None: metric = self._metric( "automation_triggered_count", - self.prometheus_cli.Counter, + prometheus_client.Counter, "Count of times an automation has been triggered", ) metric.labels(**self._labels(state)).inc() - def _handle_counter(self, state): + def _handle_counter(self, state: State) -> None: metric = self._metric( "counter_value", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Value of counter entities", ) metric.labels(**self._labels(state)).set(self.state_as_number(state)) - def _handle_update(self, state): + def _handle_update(self, state: State) -> None: metric = self._metric( "update_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Update state, indicating if an update is available (0/1)", ) value = self.state_as_number(state) @@ -695,16 +721,15 @@ class PrometheusView(HomeAssistantView): url = API_ENDPOINT name = "api:prometheus" - def __init__(self, prometheus_cli, requires_auth: bool) -> None: + def __init__(self, requires_auth: bool) -> None: """Initialize Prometheus view.""" self.requires_auth = requires_auth - self.prometheus_cli = prometheus_cli - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Handle request for Prometheus metrics.""" _LOGGER.debug("Received Prometheus metrics request") return web.Response( - body=self.prometheus_cli.generate_latest(self.prometheus_cli.REGISTRY), + body=prometheus_client.generate_latest(prometheus_client.REGISTRY), content_type=CONTENT_TYPE_TEXT_PLAIN, ) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 4012d6e8ea1..349658223f3 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -2,27 +2,43 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_DEVICES, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_ZONE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, + async_track_state_change, +) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_PROXIMITY_ZONE, DEFAULT_TOLERANCE, DOMAIN, UNITS, ) from .coordinator import ProximityDataUpdateCoordinator +from .helpers import entity_used_in _LOGGER = logging.getLogger(__name__) @@ -39,34 +55,134 @@ ZONE_SCHEMA = vol.Schema( ) CONFIG_SCHEMA = vol.Schema( - {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, extra=vol.ALLOW_EXTRA + vol.All( + cv.deprecated(DOMAIN), + {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, + ), + extra=vol.ALLOW_EXTRA, ) +async def _async_setup_legacy( + hass: HomeAssistant, entry: ConfigEntry, coordinator: ProximityDataUpdateCoordinator +) -> None: + """Legacy proximity entity handling, can be removed in 2024.8.""" + friendly_name = entry.data[CONF_NAME] + proximity = Proximity(hass, friendly_name, coordinator) + await proximity.async_added_to_hass() + proximity.async_write_ha_state() + + if used_in := entity_used_in(hass, f"{DOMAIN}.{friendly_name}"): + async_create_issue( + hass, + DOMAIN, + f"deprecated_proximity_entity_{friendly_name}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_proximity_entity", + translation_placeholders={ + "entity": f"{DOMAIN}.{friendly_name}", + "used_in": "\n- ".join([f"`{x}`" for x in used_in]), + }, + ) + + 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) + if DOMAIN in config: + for friendly_name, proximity_config in config[DOMAIN].items(): + _LOGGER.debug("import %s with config:%s", friendly_name, proximity_config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: friendly_name, + CONF_ZONE: f"zone.{proximity_config[CONF_ZONE]}", + CONF_TRACKED_ENTITIES: proximity_config[CONF_DEVICES], + CONF_IGNORED_ZONES: [ + f"zone.{zone}" + for zone in proximity_config[CONF_IGNORED_ZONES] + ], + CONF_TOLERANCE: proximity_config[CONF_TOLERANCE], + CONF_UNIT_OF_MEASUREMENT: proximity_config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit + ), + }, + ) + ) - coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) - - async_track_state_change( + async_create_issue( hass, - proximity_config[CONF_DEVICES], - coordinator.async_check_proximity_state_change, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Proximity", + }, ) - await coordinator.async_refresh() - hass.data[DOMAIN][zone] = coordinator - - proximity = Proximity(hass, zone, coordinator) - await proximity.async_added_to_hass() - proximity.async_write_ha_state() - return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Proximity from a config entry.""" + _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) + + hass.data.setdefault(DOMAIN, {}) + + coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + + entry.async_on_unload( + async_track_state_change( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_proximity_state_change, + ) + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_tracked_entity_change, + ) + ) + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + if entry.source == SOURCE_IMPORT: + await _async_setup_legacy(hass, entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + 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, [Platform.SENSOR] + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): """Representation of a Proximity.""" @@ -89,14 +205,21 @@ class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def state(self) -> str | int | float: + def data(self) -> dict[str, str | int | None]: + """Get data from coordinator.""" + return self.coordinator.data.proximity + + @property + def state(self) -> str | float: """Return the state.""" - return self.coordinator.data["dist_to_zone"] + if isinstance(distance := self.data[ATTR_DIST_TO], str): + return distance + return self.coordinator.convert_legacy(cast(int, distance)) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - ATTR_DIR_OF_TRAVEL: str(self.coordinator.data["dir_of_travel"]), - ATTR_NEAREST: str(self.coordinator.data["nearest"]), + ATTR_DIR_OF_TRAVEL: str(self.data[ATTR_DIR_OF_TRAVEL] or STATE_UNKNOWN), + ATTR_NEAREST: str(self.data[ATTR_NEAREST]), } diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py new file mode 100644 index 00000000000..f3306bebf39 --- /dev/null +++ b/homeassistant/components/proximity/config_flow.py @@ -0,0 +1,142 @@ +"""Config flow for proximity.""" +from __future__ import annotations + +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_ZONE +from homeassistant.core import State, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, +) +from homeassistant.util import slugify + +from .const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DEFAULT_PROXIMITY_ZONE, + DEFAULT_TOLERANCE, + DOMAIN, +) + +RESULT_SUCCESS = "success" + + +def _base_schema(user_input: dict[str, Any]) -> vol.Schema: + return { + vol.Required( + CONF_TRACKED_ENTITIES, default=user_input.get(CONF_TRACKED_ENTITIES, []) + ): EntitySelector( + EntitySelectorConfig( + domain=[DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN], multiple=True + ), + ), + vol.Optional( + CONF_IGNORED_ZONES, default=user_input.get(CONF_IGNORED_ZONES, []) + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN, multiple=True), + ), + vol.Required( + CONF_TOLERANCE, + default=user_input.get(CONF_TOLERANCE, DEFAULT_TOLERANCE), + ): NumberSelector( + NumberSelectorConfig(min=1, max=100, step=1), + ), + } + + +class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a proximity config flow.""" + + VERSION = 1 + + def _user_form_schema(self, user_input: dict[str, Any] | None = None) -> vol.Schema: + if user_input is None: + user_input = {} + return vol.Schema( + { + vol.Required( + CONF_ZONE, + default=user_input.get( + CONF_ZONE, f"{ZONE_DOMAIN}.{DEFAULT_PROXIMITY_ZONE}" + ), + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN), + ), + **_base_schema(user_input), + } + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return ProximityOptionsFlow(config_entry) + + 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._async_abort_entries_match(user_input) + + title = cast(State, self.hass.states.get(user_input[CONF_ZONE])).name + + slugified_existing_entry_titles = [ + slugify(e.title) for e in self._async_current_entries() + ] + + possible_title = title + tries = 1 + while slugify(possible_title) in slugified_existing_entry_titles: + tries += 1 + possible_title = f"{title} {tries}" + + return self.async_create_entry(title=possible_title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self._user_form_schema(user_input), + ) + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Import a yaml config entry.""" + return await self.async_step_user(user_input) + + +class ProximityOptionsFlow(OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema(_base_schema(user_input)) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.config_entry, data={**self.config_entry.data, **user_input} + ) + return self.async_create_entry(title=self.config_entry.title, data={}) + + return self.async_show_form( + step_id="init", + data_schema=self._user_form_schema(dict(self.config_entry.data)), + ) diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index a5cee0ffce3..e5b384b2f70 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -1,13 +1,21 @@ """Constants for Proximity integration.""" +from typing import Final + from homeassistant.const import UnitOfLength -ATTR_DIR_OF_TRAVEL = "dir_of_travel" -ATTR_DIST_TO = "dist_to_zone" -ATTR_NEAREST = "nearest" +ATTR_DIR_OF_TRAVEL: Final = "dir_of_travel" +ATTR_DIST_TO: Final = "dist_to_zone" +ATTR_ENTITIES_DATA: Final = "entities_data" +ATTR_IN_IGNORED_ZONE: Final = "is_in_ignored_zone" +ATTR_NEAREST: Final = "nearest" +ATTR_NEAREST_DIR_OF_TRAVEL: Final = "nearest_dir_of_travel" +ATTR_NEAREST_DIST_TO: Final = "nearest_dist_to_zone" +ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" CONF_TOLERANCE = "tolerance" +CONF_TRACKED_ENTITIES = "tracked_entities" DEFAULT_DIR_OF_TRAVEL = "not set" DEFAULT_DIST_TO_ZONE = "not set" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 1f1c96c9490..047ab1b6b3a 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -1,29 +1,40 @@ """Data update coordinator for the Proximity integration.""" +from collections import defaultdict from dataclasses import dataclass import logging -from typing import TypedDict +from typing import cast +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, - CONF_DEVICES, + ATTR_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_IN_IGNORED_ZONE, + ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_DIR_OF_TRAVEL, DEFAULT_DIST_TO_ZONE, DEFAULT_NEAREST, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -38,29 +49,39 @@ class StateChangedData: new_state: State | None -class ProximityData(TypedDict): - """ProximityData type class.""" +@dataclass +class ProximityData: + """ProximityCoordinatorData class.""" - dist_to_zone: str | float - dir_of_travel: str | float - nearest: str | float + proximity: dict[str, str | int | None] + entities: dict[str, dict[str, str | int | None]] + + +DEFAULT_PROXIMITY_DATA: dict[str, str | int | None] = { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, +} class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): """Proximity data update coordinator.""" + config_entry: ConfigEntry + 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.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone: str = config[CONF_ZONE] + self.proximity_zone_id: str = config[CONF_ZONE] + self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] self.unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - self.friendly_name = friendly_name + self.entity_mapping: dict[str, list[str]] = defaultdict(list) super().__init__( hass, @@ -69,189 +90,266 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): update_interval=None, ) - self.data = { - "dist_to_zone": DEFAULT_DIST_TO_ZONE, - "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, - "nearest": DEFAULT_NEAREST, - } + self.data = ProximityData(DEFAULT_PROXIMITY_DATA, {}) self.state_change_data: StateChangedData | None = None + @callback + def async_add_entity_mapping(self, tracked_entity_id: str, entity_id: str) -> None: + """Add an tracked entity to proximity entity mapping.""" + self.entity_mapping[tracked_entity_id].append(entity_id) + 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 + async def async_check_tracked_entity_change( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: + """Fetch and process tracked entity change event.""" + data = event.data + if data["action"] == "remove": + self._create_removed_tracked_entity_issue(data["entity_id"]) - entity_name = state_change_data.new_state.name - devices_to_calculate = False - devices_in_zone = [] + if data["action"] == "update" and "entity_id" in data["changes"]: + old_tracked_entity_id = data["old_entity_id"] + new_tracked_entity_id = data["entity_id"] - 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], + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_TRACKED_ENTITIES: [ + tracked_entity + for tracked_entity in self.tracked_entities + + [new_tracked_entity_id] + if tracked_entity != old_tracked_entity_id + ], + }, ) - # 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, + def convert_legacy(self, value: float | str) -> float | str: + """Round and convert given distance value.""" + if isinstance(value, str): + return value + return round( + DistanceConverter.convert( + value, + UnitOfLength.METERS, + self.unit_of_measurement, ) + ) - # Loop through each of the distances collected and work out the - # closest. - closest_device: str | None = None - dist_to_zone: float | None = None + def _calc_distance_to_zone( + self, + zone: State, + device: State, + latitude: float | None, + longitude: float | None, + ) -> int | None: + if device.state.lower() == self.proximity_zone_name.lower(): + _LOGGER.debug( + "%s: %s in zone -> distance=0", + self.name, + device.entity_id, + ) + return 0 - 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 latitude is None or longitude is None: + _LOGGER.debug( + "%s: %s has no coordinates -> distance=None", + self.name, + device.entity_id, + ) + return None - # 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, - } + distance_to_zone = distance( + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + latitude, + longitude, + ) + + # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + assert distance_to_zone is not None + return round(distance_to_zone) + + def _calc_direction_of_travel( + self, + zone: State, + device: State, + old_latitude: float | None, + old_longitude: float | None, + new_latitude: float | None, + new_longitude: float | None, + ) -> str | None: + if device.state.lower() == self.proximity_zone_name.lower(): + _LOGGER.debug( + "%s: %s in zone -> direction_of_travel=arrived", + self.name, + device.entity_id, + ) + return "arrived" - # 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 + old_latitude is None + or old_longitude is None + or new_latitude is None + or new_longitude is None ): - _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, - } + return None - # 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], + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + old_latitude, + old_longitude, ) new_distance = distance( - proximity_latitude, - proximity_longitude, - state_change_data.new_state.attributes[ATTR_LATITUDE], - state_change_data.new_state.attributes[ATTR_LONGITUDE], + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + new_latitude, + new_longitude, ) - assert new_distance is not None and old_distance is not None + + # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + assert old_distance is not None + assert new_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" + return "towards" - # 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 + if distance_travelled > self.tolerance: + return "away_from" - _LOGGER.debug( - "%s updated: distance=%s: direction=%s: device=%s", - self.friendly_name, - dist_to, - direction_of_travel, - entity_name, - ) + return "stationary" - return { - "dist_to_zone": dist_to, - "dir_of_travel": direction_of_travel, - "nearest": entity_name, + async def _async_update_data(self) -> ProximityData: + """Calculate Proximity data.""" + if (zone_state := self.hass.states.get(self.proximity_zone_id)) is None: + _LOGGER.debug( + "%s: zone %s does not exist -> reset", + self.name, + self.proximity_zone_id, + ) + return ProximityData(DEFAULT_PROXIMITY_DATA, {}) + + entities_data = self.data.entities + + # calculate distance for all tracked entities + for entity_id in self.tracked_entities: + if (tracked_entity_state := self.hass.states.get(entity_id)) is None: + if entities_data.pop(entity_id, None) is not None: + _LOGGER.debug( + "%s: %s does not exist -> remove", self.name, entity_id + ) + continue + + if entity_id not in entities_data: + _LOGGER.debug("%s: %s is new -> add", self.name, entity_id) + entities_data[entity_id] = { + ATTR_DIST_TO: None, + ATTR_DIR_OF_TRAVEL: None, + ATTR_NAME: tracked_entity_state.name, + ATTR_IN_IGNORED_ZONE: False, + } + entities_data[entity_id][ATTR_IN_IGNORED_ZONE] = ( + f"{ZONE_DOMAIN}.{tracked_entity_state.state.lower()}" + in self.ignored_zone_ids + ) + entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( + zone_state, + tracked_entity_state, + tracked_entity_state.attributes.get(ATTR_LATITUDE), + tracked_entity_state.attributes.get(ATTR_LONGITUDE), + ) + if entities_data[entity_id][ATTR_DIST_TO] is None: + _LOGGER.debug( + "%s: %s has unknown distance got -> direction_of_travel=None", + self.name, + entity_id, + ) + entities_data[entity_id][ATTR_DIR_OF_TRAVEL] = None + + # calculate direction of travel only for last updated tracked entity + if (state_change_data := self.state_change_data) is not None and ( + new_state := state_change_data.new_state + ) is not None: + _LOGGER.debug( + "%s: calculate direction of travel for %s", + self.name, + state_change_data.entity_id, + ) + + if (old_state := state_change_data.old_state) is not None: + old_lat = old_state.attributes.get(ATTR_LATITUDE) + old_lon = old_state.attributes.get(ATTR_LONGITUDE) + else: + old_lat = None + old_lon = None + + entities_data[state_change_data.entity_id][ + ATTR_DIR_OF_TRAVEL + ] = self._calc_direction_of_travel( + zone_state, + new_state, + old_lat, + old_lon, + new_state.attributes.get(ATTR_LATITUDE), + new_state.attributes.get(ATTR_LONGITUDE), + ) + + # takeover data for legacy proximity entity + proximity_data: dict[str, str | int | None] = { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, } + for entity_data in entities_data.values(): + if (distance_to := entity_data[ATTR_DIST_TO]) is None or entity_data[ + ATTR_IN_IGNORED_ZONE + ]: + continue + + if isinstance((nearest_distance_to := proximity_data[ATTR_DIST_TO]), str): + _LOGGER.debug("set first entity_data: %s", entity_data) + proximity_data = { + ATTR_DIST_TO: distance_to, + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], + ATTR_NEAREST: str(entity_data[ATTR_NAME]), + } + continue + + if cast(int, nearest_distance_to) > int(distance_to): + _LOGGER.debug("set closer entity_data: %s", entity_data) + proximity_data = { + ATTR_DIST_TO: distance_to, + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], + ATTR_NEAREST: str(entity_data[ATTR_NAME]), + } + continue + + if cast(int, nearest_distance_to) == int(distance_to): + _LOGGER.debug("set equally close entity_data: %s", entity_data) + proximity_data[ + ATTR_NEAREST + ] = f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" + + return ProximityData(proximity_data, entities_data) + + def _create_removed_tracked_entity_issue(self, entity_id: str) -> None: + """Create a repair issue for a removed tracked entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"tracked_entity_removed_{entity_id}", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="tracked_entity_removed", + translation_placeholders={"entity_id": entity_id, "name": self.name}, + ) diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py new file mode 100644 index 00000000000..3ccecbe1f19 --- /dev/null +++ b/homeassistant/components/proximity/diagnostics.py @@ -0,0 +1,69 @@ +"""Diagnostics support for Proximity.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.components.person import ATTR_USER_ID +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ProximityDataUpdateCoordinator + +TO_REDACT = { + ATTR_GPS, + ATTR_IP, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MAC, + ATTR_USER_ID, + "context", + "location_name", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + diag_data = { + "entry": entry.as_dict(), + } + + non_sensitiv_states = [ + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ] + [z.name for z in hass.states.async_all(ZONE_DOMAIN)] + + tracked_states: dict[str, dict] = {} + for tracked_entity_id in coordinator.tracked_entities: + if (state := hass.states.get(tracked_entity_id)) is None: + continue + tracked_states[tracked_entity_id] = async_redact_data( + state.as_dict(), TO_REDACT + ) + if state.state not in non_sensitiv_states: + tracked_states[tracked_entity_id]["state"] = REDACTED + + diag_data["data"] = { + "proximity": coordinator.data.proximity, + "entities": coordinator.data.entities, + "entity_mapping": coordinator.entity_mapping, + "tracked_states": tracked_states, + } + return diag_data diff --git a/homeassistant/components/proximity/helpers.py b/homeassistant/components/proximity/helpers.py new file mode 100644 index 00000000000..9c0787538e5 --- /dev/null +++ b/homeassistant/components/proximity/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for proximity.""" +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant + + +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in diff --git a/homeassistant/components/proximity/icons.json b/homeassistant/components/proximity/icons.json new file mode 100644 index 00000000000..2919c73eda0 --- /dev/null +++ b/homeassistant/components/proximity/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + }, + "nearest": { + "default": "mdi:near-me" + }, + "nearest_dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + } + } + } +} diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json index 3f1ea950d0e..b29a0f495b8 100644 --- a/homeassistant/components/proximity/manifest.json +++ b/homeassistant/components/proximity/manifest.json @@ -2,6 +2,7 @@ "domain": "proximity", "name": "Proximity", "codeowners": ["@mib1185"], + "config_flow": true, "dependencies": ["device_tracker", "zone"], "documentation": "https://www.home-assistant.io/integrations/proximity", "iot_class": "calculated", diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py new file mode 100644 index 00000000000..8eb7aae9bb9 --- /dev/null +++ b/homeassistant/components/proximity/sensor.py @@ -0,0 +1,206 @@ +"""Support for Proximity sensors.""" + +from __future__ import annotations + +from typing import NamedTuple + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +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 ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_NEAREST, + ATTR_NEAREST_DIR_OF_TRAVEL, + ATTR_NEAREST_DIST_TO, + DOMAIN, +) +from .coordinator import ProximityDataUpdateCoordinator + +DIRECTIONS = ["arrived", "away_from", "stationary", "towards"] + +SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=ATTR_DIST_TO, + translation_key=ATTR_DIST_TO, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + translation_key=ATTR_DIR_OF_TRAVEL, + device_class=SensorDeviceClass.ENUM, + options=DIRECTIONS, + ), +] + +SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=ATTR_NEAREST, + translation_key=ATTR_NEAREST, + ), + SensorEntityDescription( + key=ATTR_DIST_TO, + translation_key=ATTR_NEAREST_DIST_TO, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + translation_key=ATTR_NEAREST_DIR_OF_TRAVEL, + device_class=SensorDeviceClass.ENUM, + options=DIRECTIONS, + ), +] + + +class TrackedEntityDescriptor(NamedTuple): + """Descriptor of a tracked entity.""" + + entity_id: str + identifier: str + name: str + + +def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=coordinator.config_entry.title, + entry_type=DeviceEntryType.SERVICE, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the proximity sensors.""" + + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[ProximitySensor | ProximityTrackedEntitySensor] = [ + ProximitySensor(description, coordinator) + for description in SENSORS_PER_PROXIMITY + ] + + tracked_entity_descriptors: list[TrackedEntityDescriptor] = [] + + entity_reg = er.async_get(hass) + for tracked_entity_id in coordinator.tracked_entities: + tracked_entity_object_id = tracked_entity_id.split(".")[-1] + if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: + tracked_entity_descriptors.append( + TrackedEntityDescriptor( + tracked_entity_id, + entity_entry.id, + entity_entry.name + or entity_entry.original_name + or tracked_entity_object_id, + ) + ) + else: + tracked_entity_descriptors.append( + TrackedEntityDescriptor( + tracked_entity_id, + tracked_entity_id, + tracked_entity_object_id, + ) + ) + + entities += [ + ProximityTrackedEntitySensor( + description, coordinator, tracked_entity_descriptor + ) + for description in SENSORS_PER_ENTITY + for tracked_entity_descriptor in tracked_entity_descriptors + ] + + async_add_entities(entities) + + +class ProximitySensor(CoordinatorEntity[ProximityDataUpdateCoordinator], SensorEntity): + """Represents a Proximity sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + description: SensorEntityDescription, + coordinator: ProximityDataUpdateCoordinator, + ) -> None: + """Initialize the proximity.""" + super().__init__(coordinator) + + self.entity_description = description + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = _device_info(coordinator) + + @property + def native_value(self) -> str | float | None: + """Return native sensor value.""" + if ( + value := self.coordinator.data.proximity[self.entity_description.key] + ) == "not set": + return None + return value + + +class ProximityTrackedEntitySensor( + CoordinatorEntity[ProximityDataUpdateCoordinator], SensorEntity +): + """Represents a Proximity tracked entity sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + description: SensorEntityDescription, + coordinator: ProximityDataUpdateCoordinator, + tracked_entity_descriptor: TrackedEntityDescriptor, + ) -> None: + """Initialize the proximity.""" + super().__init__(coordinator) + + self.entity_description = description + self.tracked_entity_id = tracked_entity_descriptor.entity_id + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" + self._attr_device_info = _device_info(coordinator) + self._attr_translation_placeholders = { + "tracked_entity": tracked_entity_descriptor.name + } + + async def async_added_to_hass(self) -> None: + """Register entity mapping.""" + await super().async_added_to_hass() + self.coordinator.async_add_entity_mapping( + self.tracked_entity_id, self.entity_id + ) + + @property + def data(self) -> dict[str, str | int | None]: + """Get data from coordinator.""" + return self.coordinator.data.entities[self.tracked_entity_id] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.tracked_entity_id in self.coordinator.data.entities + ) + + @property + def native_value(self) -> str | float | None: + """Return native sensor value.""" + return self.data.get(self.entity_description.key) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 4949ec80ba1..72c95eeeeae 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,3 +1,81 @@ { - "title": "Proximity" + "title": "Proximity", + "config": { + "flow_title": "Proximity", + "step": { + "user": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "success": "Changes saved" + } + }, + "options": { + "step": { + "init": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + } + }, + "entity": { + "sensor": { + "dir_of_travel": { + "name": "{tracked_entity} Direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "nearest": { "name": "Nearest device" }, + "nearest_dir_of_travel": { + "name": "Nearest direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "nearest_dist_to_zone": { "name": "Nearest distance" } + } + }, + "issues": { + "deprecated_proximity_entity": { + "title": "The proximity entity is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::proximity::issues::deprecated_proximity_entity::title%]", + "description": "The proximity entity `{entity}` is deprecated and will be removed in `2024.8`. However it is used within the following configurations:\n- {used_in}\n\nPlease adjust any automations or scripts that use this deprecated Proximity entity.\nFor each tracked person or device one sensor for the distance and the direction of travel to/from the monitored zone is created. Additionally for each Proximity configuration one sensor which shows the nearest device or person to the monitored zone is created. With this you can use the Min/Max integration to determine the nearest and furthest distance." + } + } + } + }, + "tracked_entity_removed": { + "title": "Tracked entity has been removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::proximity::issues::tracked_entity_removed::title%]", + "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed." + } + } + } + } + } } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 59798e38957..1b05a768b64 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index b6a00bbaf10..94cf21e13df 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -131,7 +131,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): # pylint: disable=hass-enforce-coordinator-module """Update coordinator for the printer.""" config_entry: ConfigEntry @@ -176,7 +176,7 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): return timedelta(seconds=30) -class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): # pylint: disable=hass-enforce-coordinator-module """Printer update coordinator.""" async def _fetch_data(self) -> PrinterStatus: @@ -184,7 +184,7 @@ class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): return await self.api.get_status() -class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): +class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): # pylint: disable=hass-enforce-coordinator-module """Printer legacy update coordinator.""" async def _fetch_data(self) -> LegacyPrinterStatus: @@ -192,7 +192,7 @@ class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): return await self.api.get_legacy_printer() -class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): +class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): # pylint: disable=hass-enforce-coordinator-module """Job update coordinator.""" async def _fetch_data(self) -> JobInfo: @@ -200,7 +200,7 @@ class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): return await self.api.get_job() -class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): +class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """Defines a base PrusaLink entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 4a64e5abb84..cda73a7da0b 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -47,7 +47,7 @@ class PureEnergieData(NamedTuple): smartbridge: SmartBridge -class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): +class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Pure Energie data from single eindpoint.""" config_entry: ConfigEntry diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 1e78586dece..50dbb47a285 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -33,20 +33,13 @@ from .coordinator import PurpleAirDataUpdateCoordinator CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" -@dataclass(frozen=True) -class PurpleAirSensorEntityDescriptionMixin: - """Define a description mixin for PurpleAir sensor entities.""" +@dataclass(frozen=True, kw_only=True) +class PurpleAirSensorEntityDescription(SensorEntityDescription): + """Define an object to describe PurpleAir sensor entities.""" value_fn: Callable[[SensorModel], float | str | None] -@dataclass(frozen=True) -class PurpleAirSensorEntityDescription( - SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin -): - """Define an object to describe PurpleAir sensor entities.""" - - SENSOR_DESCRIPTIONS = [ PurpleAirSensorEntityDescription( key="humidity", diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 00a3a355477..af9154f5512 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -59,7 +59,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): +class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Electricity prices data from API.""" def __init__( diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 098603b9494..7b49a6b1b0d 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -20,8 +20,13 @@ from RestrictedPython.Guards import ( import voluptuous as vol from homeassistant.const import CONF_DESCRIPTION, CONF_NAME, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -107,9 +112,9 @@ def discover_scripts(hass): _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) return False - def python_script_service_handler(call: ServiceCall) -> None: + def python_script_service_handler(call: ServiceCall) -> ServiceResponse: """Handle python script service calls.""" - execute_script(hass, call.service, call.data) + return execute_script(hass, call.service, call.data, call.return_response) existing = hass.services.services.get(DOMAIN, {}).keys() for existing_service in existing: @@ -126,7 +131,12 @@ def discover_scripts(hass): for fil in glob.iglob(os.path.join(path, "*.py")): name = os.path.splitext(os.path.basename(fil))[0] - hass.services.register(DOMAIN, name, python_script_service_handler) + hass.services.register( + DOMAIN, + name, + python_script_service_handler, + supports_response=SupportsResponse.OPTIONAL, + ) service_desc = { CONF_NAME: services_dict.get(name, {}).get("name", name), @@ -137,17 +147,17 @@ def discover_scripts(hass): @bind_hass -def execute_script(hass, name, data=None): +def execute_script(hass, name, data=None, return_response=False): """Execute a script.""" filename = f"{name}.py" raise_if_invalid_filename(filename) with open(hass.config.path(FOLDER, filename), encoding="utf8") as fil: source = fil.read() - execute(hass, filename, source, data) + return execute(hass, filename, source, data, return_response=return_response) @bind_hass -def execute(hass, filename, source, data=None): +def execute(hass, filename, source, data=None, return_response=False): """Execute Python source.""" compiled = compile_restricted_exec(source, filename=filename) @@ -216,16 +226,39 @@ def execute(hass, filename, source, data=None): "hass": hass, "data": data or {}, "logger": logger, + "output": {}, } try: _LOGGER.info("Executing %s: %s", filename, data) # pylint: disable-next=exec-used exec(compiled.code, restricted_globals) # noqa: S102 + _LOGGER.debug( + "Output of python_script: `%s`:\n%s", + filename, + restricted_globals["output"], + ) + # Ensure that we're always returning a dictionary + if not isinstance(restricted_globals["output"], dict): + output_type = type(restricted_globals["output"]) + restricted_globals["output"] = {} + raise ScriptError( + f"Expected `output` to be a dictionary, was {output_type}" + ) except ScriptError as err: + if return_response: + raise ServiceValidationError(f"Error executing script: {err}") from err logger.error("Error executing script: %s", err) + return None except Exception as err: # pylint: disable=broad-except + if return_response: + raise HomeAssistantError( + f"Error executing script ({type(err).__name__}): {err}" + ) from err logger.exception("Error executing script: %s", err) + return None + + return restricted_globals["output"] class StubPrinter: diff --git a/homeassistant/components/qnap/icons.json b/homeassistant/components/qnap/icons.json new file mode 100644 index 00000000000..c83962c3089 --- /dev/null +++ b/homeassistant/components/qnap/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "sensor": { + "status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "cpu_usage": { + "default": "mdi:chip" + }, + "memory_free": { + "default": "mdi:memory" + }, + "memory_used": { + "default": "mdi:memory" + }, + "memory_percent_used": { + "default": "mdi:memory" + }, + "network_link_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "network_tx": { + "default": "mdi:upload" + }, + "network_rx": { + "default": "mdi:download" + }, + "drive_smart_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "volume_size_used": { + "default": "mdi:chart-pie" + }, + "volume_size_free": { + "default": "mdi:chart-pie" + }, + "volume_percentage_used": { + "default": "mdi:chart-pie" + } + } + } +} diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 4677d2aabb6..348759012ac 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_NAME, PERCENTAGE, + EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, @@ -31,8 +32,6 @@ ATTR_MASK = "Mask" ATTR_MAX_SPEED = "Max Speed" ATTR_MEMORY_SIZE = "Memory Size" ATTR_MODEL = "Model" -ATTR_PACKETS_TX = "Packets (TX)" -ATTR_PACKETS_RX = "Packets (RX)" ATTR_PACKETS_ERR = "Packets (Err)" ATTR_SERIAL = "Serial #" ATTR_TYPE = "Type" @@ -42,33 +41,33 @@ ATTR_VOLUME_SIZE = "Volume Size" _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", - name="Status", - icon="mdi:checkbox-marked-circle-outline", + translation_key="status", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="system_temp", - name="System Temperature", + translation_key="system_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), ) _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="cpu_temp", - name="CPU Temperature", + translation_key="cpu_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:checkbox-marked-circle-outline", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="cpu_usage", - name="CPU Usage", + translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:chip", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -76,10 +75,10 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="memory_free", - name="Memory Available", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -87,10 +86,10 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="memory_used", - name="Memory Used", + translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -98,9 +97,9 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="memory_percent_used", - name="Memory Usage", + translation_key="memory_percent_used", native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -108,15 +107,15 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_link_status", - name="Network Link", - icon="mdi:checkbox-marked-circle-outline", + translation_key="network_link_status", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="network_tx", - name="Network Up", + translation_key="network_tx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -124,10 +123,10 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="network_rx", - name="Network Down", + translation_key="network_rx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -137,16 +136,16 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="drive_smart_status", - name="SMART Status", - icon="mdi:checkbox-marked-circle-outline", + translation_key="drive_smart_status", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( key="drive_temp", - name="Temperature", + translation_key="drive_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -154,10 +153,10 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="volume_size_used", - name="Used Space", + translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:chart-pie", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -165,10 +164,10 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="volume_size_free", - name="Free Space", + translation_key="volume_size_free", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:chart-pie", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -176,9 +175,9 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="volume_percentage_used", - name="Volume Used", + translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, - icon="mdi:chart-pie", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -261,6 +260,8 @@ async def async_setup_entry( class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): """Base class for a QNAP sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: QnapCoordinator, @@ -276,6 +277,7 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): self._attr_unique_id = f"{unique_id}_{description.key}" if monitor_device: self._attr_unique_id = f"{self._attr_unique_id}_{monitor_device}" + self._attr_translation_placeholders = {"monitor_device": monitor_device} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, serial_number=unique_id, @@ -285,13 +287,6 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): manufacturer="QNAP", ) - @property - def name(self): - """Return the name of the sensor, if any.""" - if self.monitor_device is not None: - return f"{self.device_name} {self.entity_description.name} ({self.monitor_device})" - return f"{self.device_name} {self.entity_description.name}" - class QNAPCPUSensor(QNAPSensor): """A QNAP sensor that monitors CPU stats.""" @@ -407,16 +402,6 @@ class QNAPDriveSensor(QNAPSensor): if self.entity_description.key == "drive_temp": return int(data["temp_c"]) if data["temp_c"] is not None else 0 - @property - def name(self): - """Return the name of the sensor, if any.""" - server_name = self.coordinator.data["system_stats"]["system"]["name"] - - return ( - f"{server_name} {self.entity_description.name} (Drive" - f" {self.monitor_device})" - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index d535b9f0e87..ddceb487e2d 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -22,5 +22,54 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "system_temp": { + "name": "System temperature" + }, + "cpu_temp": { + "name": "CPU temperature" + }, + "cpu_usage": { + "name": "CPU usage" + }, + "memory_free": { + "name": "Memory available" + }, + "memory_used": { + "name": "Memory used" + }, + "memory_percent_used": { + "name": "Memory usage" + }, + "network_link_status": { + "name": "{monitor_device} link" + }, + "network_tx": { + "name": "{monitor_device} upload" + }, + "network_rx": { + "name": "{monitor_device} download" + }, + "drive_smart_status": { + "name": "Drive {monitor_device} status" + }, + "drive_temp": { + "name": "Drive {monitor_device} temperature" + }, + "volume_size_used": { + "name": "Used space ({monitor_device})" + }, + "volume_size_free": { + "name": "Free space ({monitor_device})" + }, + "volume_percentage_used": { + "name": "Volume used ({monitor_device})" + } + } } } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 23bd1d050a1..e3b202a9950 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.2.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py new file mode 100644 index 00000000000..97b37f6c03f --- /dev/null +++ b/homeassistant/components/rabbitair/__init__.py @@ -0,0 +1,51 @@ +"""The Rabbit Air integration.""" +from __future__ import annotations + +from rabbitair import Client, UdpClient + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.FAN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rabbit Air from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + host: str = entry.data[CONF_HOST] + token: str = entry.data[CONF_ACCESS_TOKEN] + + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + device: Client = UdpClient(host, token, zeroconf=zeroconf_instance) + + coordinator = RabbitAirDataUpdateCoordinator(hass, device) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py new file mode 100644 index 00000000000..70cd07f4d91 --- /dev/null +++ b/homeassistant/components/rabbitair/config_flow.py @@ -0,0 +1,126 @@ +"""Config flow for Rabbit Air integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from rabbitair import UdpClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + try: + try: + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + with UdpClient( + data[CONF_HOST], data[CONF_ACCESS_TOKEN], zeroconf=zeroconf_instance + ) as client: + info = await client.get_info() + except Exception as err: + _LOGGER.debug("Connection attempt failed: %s", err) + raise + except ValueError as err: + # Most likely caused by the invalid access token. + raise InvalidAccessToken from err + except asyncio.TimeoutError as err: + # Either the host doesn't respond or the auth failed. + raise TimeoutConnect from err + except OSError as err: + # Most likely caused by the invalid host. + raise InvalidHost from err + except Exception as err: + # Other possible errors. + raise CannotConnect from err + + # Return info to store in the config entry. + return {"mac": info.mac} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rabbit Air.""" + + VERSION = 1 + + _discovered_host: str | 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: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAccessToken: + errors["base"] = "invalid_access_token" + except InvalidHost: + errors["base"] = "invalid_host" + except TimeoutConnect: + errors["base"] = "timeout_connect" + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + user_input[CONF_MAC] = info["mac"] + await self.async_set_unique_id(dr.format_mac(info["mac"])) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry(title="Rabbit Air", data=user_input) + + user_input = user_input or {} + host = user_input.get(CONF_HOST, self._discovered_host) + token = user_input.get(CONF_ACCESS_TOKEN) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_ACCESS_TOKEN, default=token): vol.All( + str, vol.Length(min=32, max=32) + ), + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + mac = dr.format_mac(discovery_info.properties["id"]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + self._discovered_host = discovery_info.hostname.rstrip(".") + return await self.async_step_user() + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAccessToken(HomeAssistantError): + """Error to indicate the access token is not valid.""" + + +class InvalidHost(HomeAssistantError): + """Error to indicate the host is not valid.""" + + +class TimeoutConnect(HomeAssistantError): + """Error to indicate the connection attempt is timed out.""" diff --git a/homeassistant/components/rabbitair/const.py b/homeassistant/components/rabbitair/const.py new file mode 100644 index 00000000000..8428570faaa --- /dev/null +++ b/homeassistant/components/rabbitair/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rabbit Air integration.""" + +DOMAIN = "rabbitair" diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py new file mode 100644 index 00000000000..36c58f8700c --- /dev/null +++ b/homeassistant/components/rabbitair/coordinator.py @@ -0,0 +1,74 @@ +"""Rabbit Air Update Coordinator.""" +from collections.abc import Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from rabbitair import Client, State + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize debounce.""" + # We don't want an immediate refresh since the device needs some time + # to apply the changes and reflect the updated state. Two seconds + # should be sufficient, since the internal cycle of the device runs at + # one-second intervals. + super().__init__(hass, _LOGGER, cooldown=2.0, immediate=False) + + async def async_call(self) -> None: + """Call the function.""" + # Restart the timer. + self.async_cancel() + await super().async_call() + + def has_pending_call(self) -> bool: + """Indicate that the debouncer has a call waiting for cooldown.""" + return self._execute_at_end_of_timer + + +class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): + """Class to manage fetching data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, device: Client) -> None: + """Initialize global data updater.""" + self.device = device + super().__init__( + hass, + _LOGGER, + name="rabbitair", + update_interval=timedelta(seconds=10), + request_refresh_debouncer=RabbitAirDebouncer(hass), + ) + + async def _async_update_data(self) -> State: + return await self.device.get_state() + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + + # Skip a scheduled refresh if there is a pending requested refresh. + debouncer = cast(RabbitAirDebouncer, self._debounced_refresh) + if scheduled and debouncer.has_pending_call(): + return + + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) diff --git a/homeassistant/components/rabbitair/entity.py b/homeassistant/components/rabbitair/entity.py new file mode 100644 index 00000000000..07e49aae7cb --- /dev/null +++ b/homeassistant/components/rabbitair/entity.py @@ -0,0 +1,62 @@ +"""A base class for Rabbit Air entities.""" +from __future__ import annotations + +import logging +from typing import Any + +from rabbitair import Model + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +MODELS = { + Model.A3: "A3", + Model.BioGS: "BioGS 2.0", + Model.MinusA2: "MinusA2", + None: None, +} + + +class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]): + """Base class for Rabbit Air entity.""" + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_name = entry.title + self._attr_unique_id = entry.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.data[CONF_MAC])}, + manufacturer="Rabbit Air", + model=MODELS.get(coordinator.data.model), + name=entry.title, + sw_version=coordinator.data.wifi_firmware, + hw_version=coordinator.data.main_firmware, + ) + + def _is_model(self, model: Model | list[Model]) -> bool: + """Check the model of the device.""" + if isinstance(model, list): + return self.coordinator.data.model in model + return self.coordinator.data.model is model + + async def _set_state(self, **kwargs: Any) -> None: + """Change the state of the device.""" + _LOGGER.debug("Set state %s", kwargs) + await self.coordinator.device.set_state(**kwargs) + # Force polling of the device, because changing one parameter often + # causes other parameters to change as well. By getting updated status + # we provide a better user experience, especially if the default + # polling interval is set too long. + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py new file mode 100644 index 00000000000..46465163839 --- /dev/null +++ b/homeassistant/components/rabbitair/fan.py @@ -0,0 +1,147 @@ +"""Support for Rabbit Air fan entity.""" +from __future__ import annotations + +from typing import Any + +from rabbitair import Mode, Model, Speed + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator +from .entity import RabbitAirBaseEntity + +SPEED_LIST = [ + Speed.Silent, + Speed.Low, + Speed.Medium, + Speed.High, + Speed.Turbo, +] + +PRESET_MODE_AUTO = "Auto" +PRESET_MODE_MANUAL = "Manual" +PRESET_MODE_POLLEN = "Pollen" + +PRESET_MODES = { + PRESET_MODE_AUTO: Mode.Auto, + PRESET_MODE_MANUAL: Mode.Manual, + PRESET_MODE_POLLEN: Mode.Pollen, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RabbitAirFanEntity(coordinator, entry)]) + + +class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): + """Fan control functions of the Rabbit Air air purifier.""" + + _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, entry) + + if self._is_model(Model.MinusA2): + self._attr_preset_modes = list(PRESET_MODES) + elif self._is_model(Model.A3): + # A3 does not support Pollen mode + self._attr_preset_modes = [ + k for k in PRESET_MODES if k != PRESET_MODE_POLLEN + ] + + self._attr_speed_count = len(SPEED_LIST) + + self._get_state_from_coordinator_data() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._get_state_from_coordinator_data() + super()._handle_coordinator_update() + + def _get_state_from_coordinator_data(self) -> None: + """Populate the entity fields with values from the coordinator data.""" + data = self.coordinator.data + + # Speed as a percentage + if not data.power: + self._attr_percentage = 0 + elif data.speed is None: + self._attr_percentage = None + elif data.speed is Speed.SuperSilent: + self._attr_percentage = 1 + else: + self._attr_percentage = ordered_list_item_to_percentage( + SPEED_LIST, data.speed + ) + + # Preset mode + if not data.power or data.mode is None: + self._attr_preset_mode = None + else: + # Get key by value in dictionary + self._attr_preset_mode = next( + k for k, v in PRESET_MODES.items() if v == data.mode + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._set_state(power=True, mode=PRESET_MODES[preset_mode]) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage > 0: + value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, speed=value) + self._attr_percentage = percentage + else: + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode_value: Mode | None = None + if preset_mode is not None: + mode_value = PRESET_MODES[preset_mode] + speed_value: Speed | None = None + if percentage is not None: + speed_value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, mode=mode_value, speed=speed_value) + if percentage is not None: + self._attr_percentage = percentage + if preset_mode is not None: + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() diff --git a/homeassistant/components/rabbitair/manifest.json b/homeassistant/components/rabbitair/manifest.json new file mode 100644 index 00000000000..8f4df8afb7b --- /dev/null +++ b/homeassistant/components/rabbitair/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "rabbitair", + "name": "Rabbit Air", + "after_dependencies": ["zeroconf"], + "codeowners": ["@rabbit-air"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rabbitair", + "iot_class": "local_polling", + "requirements": ["python-rabbitair==0.0.8"], + "zeroconf": ["_rabbitair._udp.local."] +} diff --git a/homeassistant/components/rabbitair/strings.json b/homeassistant/components/rabbitair/strings.json new file mode 100644 index 00000000000..dd44a51d48f --- /dev/null +++ b/homeassistant/components/rabbitair/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index e47004f5fb7..13299b4e7dc 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -22,7 +22,7 @@ from .webhooks import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index e58341633b1..1a9d71233c2 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/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c14603fe9ca..7f395169644 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -96,7 +96,7 @@ class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[RootFolder """Fetch the data.""" root_folders = await self.api_client.async_get_root_folders() if isinstance(root_folders, RootFolder): - root_folders = [root_folders] + return [root_folders] return root_folders @@ -105,7 +105,10 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]): async def _fetch_data(self) -> list[Health]: """Fetch the health data.""" - return await self.api_client.async_get_failed_health_checks() + health = await self.api_client.async_get_failed_health_checks() + if isinstance(health, Health): + return [health] + return health class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index f5ea14e8f4e..4ab57fd6821 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -106,6 +106,7 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_precision = PRECISION_HALVES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" @@ -113,7 +114,10 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): self._attr_unique_id = self.init_data.mac self._attr_fan_modes = CT30_FAN_OPERATION_LIST self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if not isinstance(self.device, radiotherm.thermostat.CT80): return diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index e5731dc08fe..f7eab3bc2f2 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -20,11 +20,11 @@ from .coordinator import RainbirdData _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - Platform.SWITCH, - Platform.SENSOR, Platform.BINARY_SENSOR, - Platform.NUMBER, Platform.CALENDAR, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index f050e92f783..9da6372086f 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -87,7 +87,7 @@ async def async_get_type(hass, cloud_id, install_code, host): return None, None -class EagleDataCoordinator(DataUpdateCoordinator): +class EagleDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Get the latest data from the Eagle device.""" eagle100_reader: Eagle100Reader | None = None diff --git a/homeassistant/components/rainforest_raven/__init__.py b/homeassistant/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..d72b12f68c6 --- /dev/null +++ b/homeassistant/components/rainforest_raven/__init__.py @@ -0,0 +1,29 @@ +"""Integration for Rainforest RAVEn devices.""" +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 RAVEnDataCoordinator + +PLATFORMS = (Platform.SENSOR,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rainforest RAVEn device from a config entry.""" + coordinator = RAVEnDataCoordinator(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.""" + 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/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py new file mode 100644 index 00000000000..cd8ce68c7e7 --- /dev/null +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from aioraven.data import MeterType +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DEFAULT_NAME, DOMAIN + + +def _format_id(value: str | int) -> str: + if isinstance(value, str): + return value + return f"{value or 0:04X}" + + +def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: + """Generate unique id from usb attributes.""" + return ( + f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" + f"_{info.manufacturer}_{info.description}" + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rainforest RAVEn devices.""" + + def __init__(self) -> None: + """Set up flow instance.""" + self._dev_path: str | None = None + self._meter_macs: set[str] = set() + + async def _validate_device(self, dev_path: str) -> None: + self._abort_if_unique_id_configured(updates={CONF_DEVICE: dev_path}) + async with ( + asyncio.timeout(5), + RAVEnSerialDevice(dev_path) as raven_device, + ): + await raven_device.synchronize() + meters = await raven_device.get_meter_list() + if meters: + for meter in meters.meter_mac_ids or (): + meter_info = await raven_device.get_meter_info(meter=meter) + if meter_info and ( + meter_info.meter_type is None + or meter_info.meter_type == MeterType.ELECTRIC + ): + self._meter_macs.add(meter.hex()) + self._dev_path = dev_path + + async def async_step_meters( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Connect to device and discover meters.""" + errors: dict[str, str] = {} + if user_input is not None: + meter_macs = [] + for raw_mac in user_input.get(CONF_MAC, ()): + mac = bytes.fromhex(raw_mac).hex() + if mac not in meter_macs: + meter_macs.append(mac) + if meter_macs and not errors: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_DEVICE: self._dev_path, + CONF_MAC: meter_macs, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_MAC): SelectSelector( + SelectSelectorConfig( + options=sorted(self._meter_macs), + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key=CONF_MAC, + ) + ), + } + ) + return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) + + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: + """Handle USB Discovery.""" + device = discovery_info.device + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = _generate_unique_id(discovery_info) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + return self.async_abort(reason="timeout_connect") + except RAVEnConnectionError: + return self.async_abort(reason="cannot_connect") + return await self.async_step_meters() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + existing_devices = [ + entry.data[CONF_DEVICE] for entry in self._async_current_entries() + ] + unused_ports = [ + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + for port in ports + if port.device not in existing_devices + ] + if not unused_ports: + return self.async_abort(reason="no_devices_found") + + errors = {} + if user_input is not None and user_input.get(CONF_DEVICE, "").strip(): + port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))] + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, port.device + ) + unique_id = _generate_unique_id(port) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + errors[CONF_DEVICE] = "timeout_connect" + except RAVEnConnectionError: + errors[CONF_DEVICE] = "cannot_connect" + else: + return await self.async_step_meters() + + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/rainforest_raven/const.py b/homeassistant/components/rainforest_raven/const.py new file mode 100644 index 00000000000..a5269ddbc26 --- /dev/null +++ b/homeassistant/components/rainforest_raven/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rainforest RAVEn integration.""" +DEFAULT_NAME = "Rainforest RAVEn" +DOMAIN = "rainforest_raven" diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py new file mode 100644 index 00000000000..edae4f11433 --- /dev/null +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -0,0 +1,163 @@ +"""Data update coordination for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from datetime import timedelta +import logging +from typing import Any + +from aioraven.data import DeviceInfo as RAVEnDeviceInfo +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _get_meter_data( + device: RAVEnSerialDevice, meter: bytes +) -> dict[str, dict[str, Any]]: + data = {} + + sum_info = await device.get_current_summation_delivered(meter=meter) + demand_info = await device.get_instantaneous_demand(meter=meter) + price_info = await device.get_current_price(meter=meter) + + if sum_info and sum_info.meter_mac_id == meter: + data["CurrentSummationDelivered"] = asdict(sum_info) + + if demand_info and demand_info.meter_mac_id == meter: + data["InstantaneousDemand"] = asdict(demand_info) + + if price_info and price_info.meter_mac_id == meter: + data["PriceCluster"] = asdict(price_info) + + return data + + +async def _get_all_data( + device: RAVEnSerialDevice, meter_macs: list[str] +) -> dict[str, dict[str, Any]]: + data: dict[str, dict[str, Any]] = {"Meters": {}} + + for meter_mac in meter_macs: + data["Meters"][meter_mac] = await _get_meter_data( + device, bytes.fromhex(meter_mac) + ) + + network_info = await device.get_network_info() + + if network_info and network_info.link_strength: + data["NetworkInfo"] = asdict(network_info) + + return data + + +class RAVEnDataCoordinator(DataUpdateCoordinator): + """Communication coordinator for a Rainforest RAVEn device.""" + + _raven_device: RAVEnSerialDevice | None = None + _device_info: RAVEnDeviceInfo | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @property + def device_fw_version(self) -> str | None: + """Return the firmware version of the device.""" + if self._device_info: + return self._device_info.fw_version + return None + + @property + def device_hw_version(self) -> str | None: + """Return the hardware version of the device.""" + if self._device_info: + return self._device_info.hw_version + return None + + @property + def device_mac_address(self) -> str | None: + """Return the MAC address of the device.""" + if self._device_info and self._device_info.device_mac_id: + return self._device_info.device_mac_id.hex() + return None + + @property + def device_manufacturer(self) -> str | None: + """Return the manufacturer of the device.""" + if self._device_info: + return self._device_info.manufacturer + return None + + @property + def device_model(self) -> str | None: + """Return the model of the device.""" + if self._device_info: + return self._device_info.model_id + return None + + @property + def device_name(self) -> str: + """Return the product name of the device.""" + return "RAVEn Device" + + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + if self._device_info and self.device_mac_address: + return DeviceInfo( + identifiers={(DOMAIN, self.device_mac_address)}, + manufacturer=self.device_manufacturer, + model=self.device_model, + name=self.device_name, + sw_version=self.device_fw_version, + hw_version=self.device_hw_version, + ) + return None + + async def _async_update_data(self) -> dict[str, Any]: + try: + device = await self._get_device() + async with asyncio.timeout(5): + return await _get_all_data(device, self.entry.data[CONF_MAC]) + except RAVEnConnectionError as err: + if self._raven_device: + await self._raven_device.close() + self._raven_device = None + raise UpdateFailed(f"RAVEnConnectionError: {err}") from err + + async def _get_device(self) -> RAVEnSerialDevice: + if self._raven_device is not None: + return self._raven_device + + device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE]) + + async with asyncio.timeout(5): + await device.open() + + try: + await device.synchronize() + self._device_info = await device.get_device_info() + except Exception: + await device.close() + raise + + self._raven_device = device + return device diff --git a/homeassistant/components/rainforest_raven/diagnostics.py b/homeassistant/components/rainforest_raven/diagnostics.py new file mode 100644 index 00000000000..970915888ec --- /dev/null +++ b/homeassistant/components/rainforest_raven/diagnostics.py @@ -0,0 +1,43 @@ +"""Diagnostics support for a Rainforest RAVEn device.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + +TO_REDACT_CONFIG = {CONF_MAC} +TO_REDACT_DATA = {"device_mac_id", "meter_mac_id"} + + +@callback +def async_redact_meter_macs(data: dict) -> dict: + """Redact meter MAC addresses from mapping keys.""" + if not data.get("Meters"): + return data + + redacted = {**data, "Meters": {}} + for idx, mac_id in enumerate(data["Meters"]): + redacted["Meters"][f"**REDACTED{idx}**"] = data["Meters"][mac_id] + + return redacted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> Mapping[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: RAVEnDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), + "data": async_redact_meter_macs( + async_redact_data(coordinator.data, TO_REDACT_DATA) + ), + } diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json new file mode 100644 index 00000000000..900c947821d --- /dev/null +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -0,0 +1,26 @@ +{ + "domain": "rainforest_raven", + "name": "Rainforest RAVEn", + "codeowners": ["@cottsay"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", + "iot_class": "local_polling", + "requirements": ["aioraven==0.5.0"], + "usb": [ + { + "vid": "0403", + "pid": "8A28", + "manufacturer": "*rainforest*", + "description": "*raven*", + "known_devices": ["Rainforest RAVEn"] + }, + { + "vid": "04B4", + "pid": "0003", + "manufacturer": "*rainforest*", + "description": "*emu-2*", + "known_devices": ["Rainforest EMU-2"] + } + ] +} diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py new file mode 100644 index 00000000000..d1f1aebb0f3 --- /dev/null +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -0,0 +1,186 @@ +"""Sensor entity for a Rainforest RAVEn device.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_MAC, + PERCENTAGE, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + + +@dataclass(frozen=True, kw_only=True) +class RAVEnSensorEntityDescription(SensorEntityDescription): + """A class that describes RAVEn sensor entities.""" + + message_key: str + attribute_keys: list[str] | None = None + + +SENSORS = ( + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_delivered", + key="summation_delivered", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_received", + key="summation_received", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="InstantaneousDemand", + translation_key="power_demand", + key="demand", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +DIAGNOSTICS = ( + RAVEnSensorEntityDescription( + message_key="NetworkInfo", + translation_key="signal_strength", + key="link_strength", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + attribute_keys=[ + "channel", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[RAVEnSensor] = [ + RAVEnSensor(coordinator, description) for description in DIAGNOSTICS + ] + + for meter_mac_addr in entry.data[CONF_MAC]: + entities.extend( + RAVEnMeterSensor(coordinator, description, meter_mac_addr) + for description in SENSORS + ) + + meter_data = coordinator.data.get("Meters", {}).get(meter_mac_addr) or {} + if meter_data.get("PriceCluster", {}).get("currency"): + entities.append( + RAVEnMeterSensor( + coordinator, + RAVEnSensorEntityDescription( + message_key="PriceCluster", + translation_key="meter_price", + key="price", + native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", + icon="mdi:cash", + state_class=SensorStateClass.MEASUREMENT, + attribute_keys=[ + "tier", + "rate_label", + ], + ), + meter_mac_addr, + ) + ) + + async_add_entities(entities) + + +class RAVEnSensor(CoordinatorEntity[RAVEnDataCoordinator], SensorEntity): + """Rainforest RAVEn Sensor.""" + + _attr_has_entity_name = True + entity_description: RAVEnSensorEntityDescription + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{self.coordinator.device_mac_address}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return self.coordinator.data.get(self.entity_description.message_key, {}) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if self.entity_description.attribute_keys: + return { + key: self._data.get(key) + for key in self.entity_description.attribute_keys + } + return None + + @property + def native_value(self) -> StateType: + """Return native value of the sensor.""" + return str(self._data.get(self.entity_description.key)) + + +class RAVEnMeterSensor(RAVEnSensor): + """Rainforest RAVEn Meter Sensor.""" + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + meter_mac_addr: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entity_description) + self._meter_mac_addr = meter_mac_addr + self._attr_unique_id = ( + f"{self._meter_mac_addr}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return ( + self.coordinator.data.get("Meters", {}) + .get(self._meter_mac_addr, {}) + .get(self.entity_description.message_key, {}) + ) diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json new file mode 100644 index 00000000000..fb667d64d3f --- /dev/null +++ b/homeassistant/components/rainforest_raven/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "No compatible devices found" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + }, + "step": { + "meters": { + "data": { + "mac": "Meter MAC Addresses" + } + }, + "user": { + "data": { + "device": "[%key:common::config_flow::data::device%]" + } + } + } + }, + "entity": { + "sensor": { + "meter_price": { + "name": "Meter price", + "state_attributes": { + "rate_label": { "name": "Rate" }, + "tier": { "name": "Tier" } + } + }, + "power_demand": { + "name": "Meter power demand" + }, + "signal_strength": { + "name": "Meter signal strength", + "state_attributes": { + "channel": { "name": "Channel" } + } + }, + "total_energy_delivered": { + "name": "Total meter energy delivered" + }, + "total_energy_received": { + "name": "Total meter energy received" + } + } + } +} diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index f0cbfd636fa..930139acf60 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -13,10 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RainMachineData, RainMachineEntity from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, -) +from .model import RainMachineEntityDescription from .util import ( EntityDomainReplacementStrategy, async_finish_entity_domain_replacements, @@ -32,14 +29,14 @@ TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineBinarySensorDescription( - BinarySensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + BinarySensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine binary sensor.""" + data_key: str + BINARY_SENSOR_DESCRIPTIONS = ( RainMachineBinarySensorDescription( diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index a13d2069007..6309d9777a1 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -24,21 +24,14 @@ from .const import DATA_PROVISION_SETTINGS, DOMAIN from .model import RainMachineEntityDescription -@dataclass(frozen=True) -class RainMachineButtonDescriptionMixin: - """Define an entity description mixin for RainMachine buttons.""" - - push_action: Callable[[Controller], Awaitable] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineButtonDescription( - ButtonEntityDescription, - RainMachineEntityDescription, - RainMachineButtonDescriptionMixin, + ButtonEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine button description.""" + push_action: Callable[[Controller], Awaitable] + BUTTON_KIND_REBOOT = "reboot" diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index dabae5ff8c6..1c4c78564f6 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2023.06.0"], + "requirements": ["regenmaschine==2024.01.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index e45448c0fe4..e7f166b67dd 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -4,29 +4,8 @@ from dataclasses import dataclass from homeassistant.helpers.entity import EntityDescription -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinApiCategory: - """Define an entity description mixin to include an API category.""" +@dataclass(frozen=True, kw_only=True) +class RainMachineEntityDescription(EntityDescription): + """Describe a RainMachine entity.""" api_category: str - - -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinDataKey: - """Define an entity description mixin to include a data payload key.""" - - data_key: str - - -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinUid: - """Define an entity description mixin to include an activity UID.""" - - uid: int - - -@dataclass(frozen=True) -class RainMachineEntityDescription( - EntityDescription, RainMachineEntityDescriptionMixinApiCategory -): - """Describe a RainMachine entity.""" diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 513c02ddc19..893c1afa8da 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -15,21 +15,18 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import RainMachineData, RainMachineEntity from .const import DATA_RESTRICTIONS_UNIVERSAL, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, -) +from .model import RainMachineEntityDescription from .util import key_exists -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSelectDescription( - SelectEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + SelectEntityDescription, RainMachineEntityDescription ): """Describe a generic RainMachine select.""" + data_key: str + @dataclass class FreezeProtectionSelectOption: @@ -40,20 +37,13 @@ class FreezeProtectionSelectOption: metric_label: str -@dataclass(frozen=True) -class FreezeProtectionTemperatureMixin: - """Define an entity description mixin to include an options list.""" +@dataclass(frozen=True, kw_only=True) +class FreezeProtectionSelectDescription(RainMachineSelectDescription): + """Describe a freeze protection temperature select.""" extended_options: list[FreezeProtectionSelectOption] -@dataclass(frozen=True) -class FreezeProtectionSelectDescription( - RainMachineSelectDescription, FreezeProtectionTemperatureMixin -): - """Describe a freeze protection temperature select.""" - - TYPE_FREEZE_PROTECTION_TEMPERATURE = "freeze_protection_temperature" SELECT_DESCRIPTIONS = ( diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 624deeb46c6..ed9b8cc0142 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -21,11 +21,7 @@ from homeassistant.util.dt import utc_from_timestamp, utcnow from . import RainMachineData, RainMachineEntity from .const import DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_ZONES, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, - RainMachineEntityDescriptionMixinUid, -) +from .model import RainMachineEntityDescription from .util import ( RUN_STATE_MAP, EntityDomainReplacementStrategy, @@ -48,23 +44,23 @@ TYPE_RAIN_SENSOR_RAIN_START = "rain_sensor_rain_start" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSensorDataDescription( - SensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + SensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine sensor.""" + data_key: str -@dataclass(frozen=True) + +@dataclass(frozen=True, kw_only=True) class RainMachineSensorCompletionTimerDescription( - SensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinUid, + SensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine completion timer sensor.""" + uid: int + SENSOR_DESCRIPTIONS = ( RainMachineSensorDataDescription( diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index b47396bc9e5..8450cb7d5e6 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -31,11 +31,7 @@ from .const import ( DEFAULT_ZONE_RUN, DOMAIN, ) -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, - RainMachineEntityDescriptionMixinUid, -) +from .model import RainMachineEntityDescription from .util import RUN_STATE_MAP, key_exists ATTR_AREA = "area" @@ -134,27 +130,26 @@ def raise_on_request_error( return decorator -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSwitchDescription( - SwitchEntityDescription, - RainMachineEntityDescription, + SwitchEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine switch.""" -@dataclass(frozen=True) -class RainMachineActivitySwitchDescription( - RainMachineSwitchDescription, RainMachineEntityDescriptionMixinUid -): +@dataclass(frozen=True, kw_only=True) +class RainMachineActivitySwitchDescription(RainMachineSwitchDescription): """Describe a RainMachine activity (program/zone) switch.""" + uid: int -@dataclass(frozen=True) -class RainMachineRestrictionSwitchDescription( - RainMachineSwitchDescription, RainMachineEntityDescriptionMixinDataKey -): + +@dataclass(frozen=True, kw_only=True) +class RainMachineRestrictionSwitchDescription(RainMachineSwitchDescription): """Describe a RainMachine restriction switch.""" + data_key: str + TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED = "freeze_protect_enabled" TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING = "hot_days_extra_watering" diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 64917b6d721..dfb03b11b5d 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -84,7 +84,7 @@ def key_exists(data: dict[str, Any], search_key: str) -> bool: return False -class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): +class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module """Define an extended DataUpdateCoordinator.""" config_entry: ConfigEntry diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 0c5b4a8b0dd..a6d330e6151 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -54,6 +54,8 @@ async def async_setup_entry( class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" self._attr_name = config.get(CONF_NAME) diff --git a/homeassistant/components/random/icons.json b/homeassistant/components/random/icons.json new file mode 100644 index 00000000000..83d5ecd0688 --- /dev/null +++ b/homeassistant/components/random/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "binary_sensor": { + "random": { + "default": "mdi:dice-multiple" + } + }, + "sensor": { + "random": { + "default": "mdi:dice-multiple" + } + } + } +} diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index f1ca4290d83..8cc21e34ce9 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,6 +65,8 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" self._attr_name = config.get(CONF_NAME) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a8746a0a807..07591c468b8 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,7 +11,7 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select @@ -104,7 +104,6 @@ from .tasks import ( EntityIDPostMigrationTask, EventIdMigrationTask, EventsContextIDMigrationTask, - EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, @@ -120,6 +119,7 @@ from .tasks import ( WaitTask, ) from .util import ( + async_create_backup_failure_issue, build_mysqldb_conv, dburl_to_path, end_incomplete_runs, @@ -189,7 +189,7 @@ class Recorder(threading.Thread): self.keep_days = keep_days self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval - self._queue: queue.SimpleQueue[RecorderTask] = queue.SimpleQueue() + self._queue: queue.SimpleQueue[RecorderTask | Event] = queue.SimpleQueue() self.db_url = uri self.db_max_retries = db_max_retries self.db_retry_wait = db_retry_wait @@ -278,7 +278,7 @@ class Recorder(threading.Thread): raise RuntimeError("The database connection has not been established") return self._get_session() - def queue_task(self, task: RecorderTask) -> None: + def queue_task(self, task: RecorderTask | Event) -> None: """Add a task to the recorder queue.""" self._queue.put(task) @@ -306,7 +306,6 @@ class Recorder(threading.Thread): entity_filter = self.entity_filter exclude_event_types = self.exclude_event_types queue_put = self._queue.put_nowait - event_task = EventTask @callback def _event_listener(event: Event) -> None: @@ -315,23 +314,23 @@ class Recorder(threading.Thread): return if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: - queue_put(event_task(event)) + queue_put(event) return if isinstance(entity_id, str): if entity_filter(entity_id): - queue_put(event_task(event)) + queue_put(event) return if isinstance(entity_id, list): for eid in entity_id: if entity_filter(eid): - queue_put(event_task(event)) + queue_put(event) return return # Unknown what it is. - queue_put(event_task(event)) + queue_put(event) self._event_listener = self.hass.bus.async_listen( MATCH_ALL, @@ -857,31 +856,35 @@ class Recorder(threading.Thread): # with a commit every time the event time # has changed. This reduces the disk io. queue_ = self._queue - startup_tasks: list[RecorderTask] = [] - while not queue_.empty() and (task := queue_.get_nowait()): - startup_tasks.append(task) - self._pre_process_startup_tasks(startup_tasks) - for task in startup_tasks: - self._guarded_process_one_task_or_recover(task) + startup_task_or_events: list[RecorderTask | Event] = [] + while not queue_.empty() and (task_or_event := queue_.get_nowait()): + startup_task_or_events.append(task_or_event) + self._pre_process_startup_events(startup_task_or_events) + for task in startup_task_or_events: + self._guarded_process_one_task_or_event_or_recover(task) # Clear startup tasks since this thread runs forever # and we don't want to hold them in memory - del startup_tasks + del startup_task_or_events self.stop_requested = False while not self.stop_requested: - self._guarded_process_one_task_or_recover(queue_.get()) + self._guarded_process_one_task_or_event_or_recover(queue_.get()) - def _pre_process_startup_tasks(self, startup_tasks: list[RecorderTask]) -> None: - """Pre process startup tasks.""" + def _pre_process_startup_events( + self, startup_task_or_events: list[RecorderTask | Event] + ) -> None: + """Pre process startup events.""" # Prime all the state_attributes and event_data caches # before we start processing events state_change_events: list[Event] = [] non_state_change_events: list[Event] = [] - for task in startup_tasks: - if isinstance(task, EventTask): - event_ = task.event + for task_or_event in startup_task_or_events: + # Event is never subclassed so we can + # use a fast type check + if type(task_or_event) is Event: # noqa: E721 + event_ = task_or_event if event_.event_type == EVENT_STATE_CHANGED: state_change_events.append(event_) else: @@ -894,20 +897,31 @@ class Recorder(threading.Thread): self.states_meta_manager.load(state_change_events, session) self.state_attributes_manager.load(state_change_events, session) - def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: + def _guarded_process_one_task_or_event_or_recover( + self, task: RecorderTask | Event + ) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" _LOGGER.debug("Processing task: %s", task) try: - self._process_one_task_or_recover(task) + self._process_one_task_or_event_or_recover(task) except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error while processing event %s: %s", task, err) - def _process_one_task_or_recover(self, task: RecorderTask) -> None: - """Process an event, reconnect, or recover a malformed database.""" + def _process_one_task_or_event_or_recover(self, task: RecorderTask | Event) -> None: + """Process a task or event, reconnect, or recover a malformed database.""" try: + # Almost everything coming in via the queue + # is an Event so we can process it directly + # and since its never subclassed, we can + # use a fast type check + if type(task) is Event: # noqa: E721 + self._process_one_event(task) + return # If its not an event, commit everything # that is pending before running the task - if task.commit_before: + if TYPE_CHECKING: + assert isinstance(task, RecorderTask) + if not task.commit_before: self._commit_event_session_or_retry() return task.run(self) except exc.DatabaseError as err: @@ -993,9 +1007,11 @@ class Recorder(threading.Thread): def _async_set_database_locked(task: DatabaseLockTask) -> None: task.database_locked.set() + local_start_time = dt_util.now() + hass = self.hass with write_lock_db_sqlite(self): # Notify that lock is being held, wait until database can be used again. - self.hass.add_job(_async_set_database_locked, task) + hass.add_job(_async_set_database_locked, task) while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT): if self._reached_max_backlog_percentage(90): _LOGGER.warning( @@ -1007,6 +1023,9 @@ class Recorder(threading.Thread): self.backlog, ) task.queue_overflow = True + hass.add_job( + async_create_backup_failure_issue, self.hass, local_start_time + ) break _LOGGER.info( "Database queue backlog reached %d entries during backup", diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index b864e104ae6..7c7d9a743f3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -40,7 +40,6 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State -from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -296,7 +295,7 @@ class Events(Base): event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, - time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), + time_fired_ts=event.time_fired_timestamp, context_id=None, context_id_bin=ulid_to_bytes_or_none(event.context.id), context_user_id=None, @@ -495,16 +494,16 @@ class States(Base): # None state means the state was removed from the state machine if state is None: dbstate.state = "" - dbstate.last_updated_ts = dt_util.utc_to_timestamp(event.time_fired) + dbstate.last_updated_ts = event.time_fired_timestamp dbstate.last_changed_ts = None return dbstate dbstate.state = state.state - dbstate.last_updated_ts = dt_util.utc_to_timestamp(state.last_updated) + dbstate.last_updated_ts = state.last_updated_timestamp if state.last_updated == state.last_changed: dbstate.last_changed_ts = None else: - dbstate.last_changed_ts = dt_util.utc_to_timestamp(state.last_changed) + dbstate.last_changed_ts = state.last_changed_timestamp return dbstate @@ -563,7 +562,6 @@ class StateAttributes(Base): @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, EntityInfo], dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" @@ -571,9 +569,13 @@ class StateAttributes(Base): # None state means the state was removed from the state machine if state is None: return b"{}" - exclude_attrs = set(ALL_DOMAIN_EXCLUDE_ATTRS) if state_info := state.state_info: - exclude_attrs |= state_info["unrecorded_attributes"] + exclude_attrs = { + *ALL_DOMAIN_EXCLUDE_ATTRS, + *state_info["unrecorded_attributes"], + } + else: + exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes bytes_result = encoder( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index b630a71daff..13ba7400952 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/models/state.py b/homeassistant/components/recorder/models/state.py index 73e7798b9f5..5f469638ec0 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime import logging -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy.engine.row import Row @@ -17,10 +17,16 @@ from homeassistant.core import Context, State import homeassistant.util.dt as dt_util from .state_attributes import decode_attributes_from_source -from .time import process_timestamp + +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property _LOGGER = logging.getLogger(__name__) +EMPTY_CONTEXT = Context(id=None) + def extract_metadata_ids( entity_id_to_metadata_id: dict[str, int | None], @@ -36,15 +42,6 @@ def extract_metadata_ids( class LazyState(State): """A lazy version of core State after schema 31.""" - __slots__ = [ - "_row", - "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_context", - "attr_cache", - ] - def __init__( # pylint: disable=super-init-not-called self, row: Row, @@ -61,61 +58,35 @@ class LazyState(State): self.state = state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = last_updated_ts or start_time_ts - self._last_changed_ts: float | None = None - self._context: Context | None = None self.attr_cache = attr_cache + self.context = EMPTY_CONTEXT - @property # type: ignore[override] + @cached_property # type: ignore[override] def attributes(self) -> dict[str, Any]: """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_source( - getattr(self._row, "attributes", None), self.attr_cache - ) - return self._attributes + return decode_attributes_from_source( + getattr(self._row, "attributes", None), self.attr_cache + ) - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value + @cached_property + def _last_changed_ts(self) -> float | None: + """Last changed timestamp.""" + return getattr(self._row, "last_changed_ts", None) - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: + @cached_property + def last_changed(self) -> datetime: # type: ignore[override] """Last changed datetime.""" - if self._last_changed_ts is None: - self._last_changed_ts = ( - getattr(self._row, "last_changed_ts", None) or self._last_updated_ts - ) - return dt_util.utc_from_timestamp(self._last_changed_ts) + return dt_util.utc_from_timestamp( + self._last_changed_ts or self._last_updated_ts + ) - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed_ts = process_timestamp(value).timestamp() - - @property - def last_updated(self) -> datetime: + @cached_property + def last_updated(self) -> datetime: # type: ignore[override] """Last updated datetime.""" - assert self._last_updated_ts is not None + if TYPE_CHECKING: + assert self._last_updated_ts is not None return dt_util.utc_from_timestamp(self._last_updated_ts) - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated_ts = process_timestamp(value).timestamp() - def as_dict(self) -> dict[str, Any]: # type: ignore[override] """Return a dict representation of the LazyState. diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 8dd539f84f3..0b63bb8daa2 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -794,4 +794,6 @@ def purge_entity_data( _LOGGER.debug("Purging entity data hasn't fully completed yet") return False + _purge_old_entity_ids(instance, session) + return True diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ad6cdd31e2c..5abe395a8d7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,7 +11,6 @@ from itertools import chain, groupby 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 @@ -31,6 +30,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -42,6 +42,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) from .const import ( @@ -115,7 +116,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( 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(), ) @@ -126,6 +127,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, + **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, **{ unit: ElectricPotentialConverter @@ -140,11 +142,23 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS}, **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS}, **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, + **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS}, } 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__) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 24f0d806edd..74b248354d7 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -12,6 +12,10 @@ "maria_db_range_index_regression": { "title": "Update MariaDB to {min_version} or later resolve a significant performance issue", "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version." + }, + "backup_failed_out_of_resources": { + "title": "Database backup failed due to lack of resources", + "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." } }, "services": { @@ -29,7 +33,7 @@ }, "apply_filter": { "name": "Apply filter", - "description": "Applys `entity_id` and `event_type` filters in addition to time-based purge." + "description": "Apply `entity_id` and `event_type` filters in addition to time-based purge." } } }, diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 725bacae71c..ddaf8cb4fca 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session from homeassistant.core import Event -from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from ..db_schema import StateAttributes @@ -37,15 +36,12 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) self.active = True # always active - self._entity_sources = entity_sources(recorder.hass) def serialize_from_event(self, event: Event) -> bytes | None: """Serialize event data.""" try: return StateAttributes.shared_attrs_bytes_from_event( - event, - self._entity_sources, - self.recorder.dialect_name, + event, self.recorder.dialect_name ) except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 07be6202a0c..c062eb3915f 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -10,7 +10,6 @@ import logging import threading from typing import TYPE_CHECKING, Any -from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics @@ -268,19 +267,6 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass(slots=True) -class EventTask(RecorderTask): - """An event to be processed.""" - - event: Event - commit_before = False - - def run(self, instance: Recorder) -> None: - """Handle the task.""" - # pylint: disable-next=[protected-access] - instance._process_one_event(self.event) - - @dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4a1bf940b24..f684160f86f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -470,6 +470,24 @@ def _async_create_mariadb_range_index_regression_issue( ) +@callback +def async_create_backup_failure_issue( + hass: HomeAssistant, + local_start_time: datetime, +) -> None: + """Create an issue when the backup fails because we run out of resources.""" + ir.async_create_issue( + hass, + DOMAIN, + "backup_failed_out_of_resources", + is_fixable=False, + severity=ir.IssueSeverity.CRITICAL, + learn_more_url="https://www.home-assistant.io/integrations/recorder", + translation_key="backup_failed_out_of_resources", + translation_placeholders={"start_time": local_start_time.strftime("%H:%M:%S")}, + ) + + def setup_connection_for_dialect( instance: Recorder, dialect_name: str, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 733dafeba27..39821cb9699 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -12,11 +12,12 @@ from homeassistant.components.websocket_api import messages from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -28,6 +29,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) from .models import StatisticPeriod @@ -56,6 +58,7 @@ UNIT_SCHEMA = vol.Schema( { vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS), vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), @@ -67,6 +70,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), + vol.Optional("volume_flow_rate"): vol.In(VolumeFlowRateConverter.VALID_UNITS), } ) @@ -97,9 +101,9 @@ def _ws_get_statistic_during_period( statistic_id: str, types: set[Literal["max", "mean", "min", "change"]] | None, units: dict[str, str], -) -> str: +) -> bytes: """Fetch statistics and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, statistic_during_period( @@ -155,7 +159,7 @@ def _ws_get_statistics_during_period( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], -) -> str: +) -> bytes: """Fetch statistics and convert them to json in the executor.""" result = statistics_during_period( hass, @@ -174,7 +178,7 @@ def _ws_get_statistics_during_period( item["end"] = int(end * 1000) if (last_reset := item.get("last_reset")) is not None: item["last_reset"] = int(last_reset * 1000) - return JSON_DUMP(messages.result_message(msg_id, result)) + return json_bytes(messages.result_message(msg_id, result)) async def ws_handle_get_statistics_during_period( @@ -242,12 +246,12 @@ def _ws_get_list_statistic_ids( hass: HomeAssistant, msg_id: int, statistic_type: Literal["mean"] | Literal["sum"] | None = None, -) -> str: +) -> bytes: """Fetch a list of available statistic_id and convert them to JSON. Runs in the executor. """ - return JSON_DUMP( + return json_bytes( messages.result_message(msg_id, list_statistic_ids(hass, None, statistic_type)) ) diff --git a/homeassistant/components/remote/icons.json b/homeassistant/components/remote/icons.json new file mode 100644 index 00000000000..07526a4bc79 --- /dev/null +++ b/homeassistant/components/remote/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:remote", + "state": { + "off": "mdi:remote-off" + } + } + }, + "services": { + "delete_command": "mdi:delete", + "learn_command": "mdi:school", + "send_command": "mdi:remote", + "toggle": "mdi:remote", + "turn_off": "mdi:remote-off", + "turn_on": "mdi:remote" + } +} diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 2a9c13be543..aee5dec0599 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.FAN, Platform.NUMBER, Platform.SENSOR, + Platform.TIME, ] diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 380a83b6818..801c25e6ab2 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -17,13 +17,11 @@ from renson_endura_delta.field_enum import ( CURRENT_AIRFLOW_INGOING_FIELD, CURRENT_LEVEL_FIELD, DAY_POLLUTION_FIELD, - DAYTIME_FIELD, FILTER_REMAIN_FIELD, HUMIDITY_FIELD, INDOOR_TEMP_FIELD, MANUAL_LEVEL_FIELD, NIGHT_POLLUTION_FIELD, - NIGHTTIME_FIELD, OUTDOOR_TEMP_FIELD, FieldEnum, ) @@ -185,20 +183,6 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=["off", "level1", "level2", "level3", "level4", "breeze"], ), - RensonSensorEntityDescription( - key="DAYTIME_FIELD", - translation_key="start_day_time", - field=DAYTIME_FIELD, - raw_format=False, - entity_registry_enabled_default=False, - ), - RensonSensorEntityDescription( - key="NIGHTTIME_FIELD", - translation_key="start_night_time", - field=NIGHTTIME_FIELD, - raw_format=False, - entity_registry_enabled_default=False, - ), RensonSensorEntityDescription( key="DAY_POLLUTION_FIELD", translation_key="day_pollution_level", diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index a826b5a3dd3..da385ef07bd 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -24,6 +24,14 @@ "name": "Reset filter counter" } }, + "time": { + "day_time": { + "name": "Start time of the day" + }, + "night_time": { + "name": "Start time of the night" + } + }, "number": { "filter_change": { "name": "Filter clean/replacement" @@ -125,12 +133,6 @@ "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]" } }, - "start_day_time": { - "name": "Start day time" - }, - "start_night_time": { - "name": "Start night time" - }, "day_pollution_level": { "name": "Day pollution level", "state": { diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py new file mode 100644 index 00000000000..57d6869a72c --- /dev/null +++ b/homeassistant/components/renson/time.py @@ -0,0 +1,100 @@ +"""Renson ventilation unit time.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, time + +from renson_endura_delta.field_enum import DAYTIME_FIELD, NIGHTTIME_FIELD, FieldEnum +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonData +from .const import DOMAIN +from .coordinator import RensonCoordinator +from .entity import RensonEntity + + +@dataclass(kw_only=True, frozen=True) +class RensonTimeEntityDescription(TimeEntityDescription): + """Class describing Renson time entity.""" + + action_fn: Callable[[RensonVentilation, str], None] + field: FieldEnum + + +ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = ( + RensonTimeEntityDescription( + key="day_time", + entity_category=EntityCategory.CONFIG, + translation_key="day_time", + action_fn=lambda api, time: api.set_day_time(time), + field=DAYTIME_FIELD, + ), + RensonTimeEntityDescription( + key="night_time", + translation_key="night_time", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api, time: api.set_night_time(time), + field=NIGHTTIME_FIELD, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson time platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonTime(description, data.coordinator) for description in ENTITY_DESCRIPTIONS + ] + + async_add_entities(entities) + + +class RensonTime(RensonEntity, TimeEntity): + """Representation of a Renson time entity.""" + + entity_description: RensonTimeEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + description: RensonTimeEntityDescription, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, coordinator.api, coordinator) + + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + all_data = self.coordinator.data + + value = self.api.get_field_value(all_data, self.entity_description.field.name) + + self._attr_native_value = datetime.strptime( + value, + "%H:%M", + ).time() + + super()._handle_coordinator_update() + + def set_value(self, value: time) -> None: + """Triggers the action.""" + + string_value = value.strftime("%H:%M") + self.entity_description.action_fn(self.api, string_value) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 09869b06e96..b27976eaa0e 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -371,6 +371,76 @@ NUMBER_ENTITIES = ( 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)), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 04dd0e787ac..92e9a6164f8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -308,6 +308,21 @@ }, "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": { diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 0c6230e4c35..78a3c10bbe4 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -1,7 +1,6 @@ """The repairs websocket API.""" from __future__ import annotations -import dataclasses from http import HTTPStatus from typing import Any @@ -30,6 +29,7 @@ from .const import DOMAIN @callback def async_setup(hass: HomeAssistant) -> None: """Set up the repairs websocket API.""" + websocket_api.async_register_command(hass, ws_get_issue_data) websocket_api.async_register_command(hass, ws_ignore_issue) websocket_api.async_register_command(hass, ws_list_issues) @@ -37,6 +37,29 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(RepairsFlowResourceView(hass.data[DOMAIN]["flow_manager"])) +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "repairs/get_issue_data", + vol.Required("domain"): str, + vol.Required("issue_id"): str, + } +) +def ws_get_issue_data( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Fix an issue.""" + issue_registry = async_get_issue_registry(hass) + if not (issue := issue_registry.async_get_issue(msg["domain"], msg["issue_id"])): + connection.send_error( + msg["id"], + "unknown_issue", + f"Issue '{msg['issue_id']}' not found", + ) + return + connection.send_result(msg["id"], {"issue_data": issue.data}) + + @callback @websocket_api.websocket_command( { @@ -65,21 +88,25 @@ def ws_list_issues( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of issues.""" - - def ws_dict(kv_pairs: list[tuple[Any, Any]]) -> dict[Any, Any]: - excluded_keys = ("active", "data", "is_persistent") - result = {k: v for k, v in kv_pairs if k not in excluded_keys} - result["ignored"] = result["dismissed_version"] is not None - result["created"] = result["created"].isoformat() - return result - issue_registry = async_get_issue_registry(hass) issues = [ - dataclasses.asdict(issue, dict_factory=ws_dict) + { + "breaks_in_ha_version": issue.breaks_in_ha_version, + "created": issue.created, + "dismissed_version": issue.dismissed_version, + "ignored": issue.dismissed_version is not None, + "domain": issue.domain, + "is_fixable": issue.is_fixable, + "issue_domain": issue.issue_domain, + "issue_id": issue.issue_id, + "learn_more_url": issue.learn_more_url, + "severity": issue.severity, + "translation_key": issue.translation_key, + "translation_placeholders": issue.translation_placeholders, + } for issue in issue_registry.issues.values() if issue.active ] - connection.send_result(msg["id"], {"issues": issues}) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index dcf790748ec..c99df16170b 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,7 +1,11 @@ """Support for exposing regular REST commands as services.""" +from __future__ import annotations + import asyncio from http import HTTPStatus +from json.decoder import JSONDecodeError import logging +from typing import Any import aiohttp from aiohttp import hdrs @@ -18,7 +22,14 @@ from homeassistant.const import ( CONF_VERIFY_SSL, SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config @@ -68,7 +79,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if conf is None: return - existing = hass.services.async_services().get(DOMAIN, {}) + existing = hass.services.async_services_for_domain(DOMAIN) for existing_service in existing: if existing_service == SERVICE_RELOAD: continue @@ -78,9 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_rest_command(name, command_config) @callback - def async_register_rest_command(name, command_config): + def async_register_rest_command(name: str, command_config: dict[str, Any]) -> None: """Create service for rest command.""" - websession = async_get_clientsession(hass, command_config.get(CONF_VERIFY_SSL)) + websession = async_get_clientsession(hass, command_config[CONF_VERIFY_SSL]) timeout = command_config[CONF_TIMEOUT] method = command_config[CONF_METHOD] @@ -98,17 +109,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: template_payload = command_config[CONF_PAYLOAD] template_payload.hass = hass - template_headers = None - if CONF_HEADERS in command_config: - template_headers = command_config[CONF_HEADERS] - for template_header in template_headers.values(): - template_header.hass = hass + template_headers = command_config.get(CONF_HEADERS, {}) + for template_header in template_headers.values(): + template_header.hass = hass - content_type = None - if CONF_CONTENT_TYPE in command_config: - content_type = command_config[CONF_CONTENT_TYPE] + content_type = command_config.get(CONF_CONTENT_TYPE) - async def async_service_handler(service: ServiceCall) -> None: + async def async_service_handler(service: ServiceCall) -> ServiceResponse: """Execute a shell command service.""" payload = None if template_payload: @@ -123,17 +130,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: variables=service.data, parse_result=False ) - headers = None - if template_headers: - headers = {} - for header_name, template_header in template_headers.items(): - headers[header_name] = template_header.async_render( - variables=service.data, parse_result=False - ) + headers = {} + for header_name, template_header in template_headers.items(): + headers[header_name] = template_header.async_render( + variables=service.data, parse_result=False + ) if content_type: - if headers is None: - headers = {} headers[hdrs.CONTENT_TYPE] = content_type try: @@ -141,7 +144,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: request_url, data=payload, auth=auth, - headers=headers, + headers=headers or None, timeout=timeout, ) as response: if response.status < HTTPStatus.BAD_REQUEST: @@ -159,18 +162,53 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: payload, ) - except asyncio.TimeoutError: - _LOGGER.warning("Timeout call %s", request_url) + if not service.return_response: + return None + + _content = None + try: + if response.content_type == "application/json": + _content = await response.json() + else: + _content = await response.text() + except (JSONDecodeError, AttributeError) as err: + raise HomeAssistantError( + f"Response of '{request_url}' could not be decoded as JSON", + translation_domain=DOMAIN, + translation_key="decoding_error", + translation_placeholders={"decoding_type": "json"}, + ) from err + + except UnicodeDecodeError as err: + raise HomeAssistantError( + f"Response of '{request_url}' could not be decoded as text", + translation_domain=DOMAIN, + translation_key="decoding_error", + translation_placeholders={"decoding_type": "text"}, + ) from err + return {"content": _content, "status": response.status} + + except asyncio.TimeoutError as err: + raise HomeAssistantError( + f"Timeout when calling resource '{request_url}'", + translation_domain=DOMAIN, + translation_key="timeout", + ) from err except aiohttp.ClientError as err: - _LOGGER.error( - "Client error. Url: %s. Error: %s", - request_url, - err, - ) + raise HomeAssistantError( + f"Client error occurred when calling resource '{request_url}'", + translation_domain=DOMAIN, + translation_key="client_error", + ) from err # register services - hass.services.async_register(DOMAIN, name, async_service_handler) + hass.services.async_register( + DOMAIN, + name, + async_service_handler, + supports_response=SupportsResponse.OPTIONAL, + ) for name, command_config in config[DOMAIN].items(): async_register_rest_command(name, command_config) diff --git a/homeassistant/components/rest_command/strings.json b/homeassistant/components/rest_command/strings.json index 15f59ec8e29..8a48cddace3 100644 --- a/homeassistant/components/rest_command/strings.json +++ b/homeassistant/components/rest_command/strings.json @@ -4,5 +4,16 @@ "name": "[%key:common::action::reload%]", "description": "Reloads RESTful commands from the YAML-configuration." } + }, + "exceptions": { + "timeout": { + "message": "Timeout while waiting for response from the server" + }, + "client_error": { + "message": "An error occurred while requesting the resource" + }, + "decoding_error": { + "message": "The response from the server could not be decoded as {decoding_type}" + } } } diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 60e2b0fef58..42b6d9a3ecf 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -254,7 +254,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) # If HA is not stopping, initiate new connection - if hass.state != CoreState.stopping: + if hass.state is not CoreState.stopping: _LOGGER.warning("Disconnected from Rflink, reconnecting") hass.async_create_task(connect()) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index cfacc627744..ffbc3d26421 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -76,12 +76,12 @@ def _bytearray_string(data: Any) -> bytearray: SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) PLATFORMS = [ - Platform.SWITCH, - Platform.SENSOR, - Platform.LIGHT, Platform.BINARY_SENSOR, Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, Platform.SIREN, + Platform.SWITCH, ] diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 157a62df05b..26fdc6d0575 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -1,37 +1,25 @@ """Support for Ring Doorbell/Chimes.""" from __future__ import annotations -import asyncio -from collections.abc import Callable -from datetime import timedelta from functools import partial import logging -from typing import Any import ring_doorbell from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.async_ import run_callback_threadsafe from .const import ( - DEVICES_SCAN_INTERVAL, DOMAIN, - HEALTH_SCAN_INTERVAL, - HISTORY_SCAN_INTERVAL, - NOTIFICATIONS_SCAN_INTERVAL, PLATFORMS, RING_API, RING_DEVICES, RING_DEVICES_COORDINATOR, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, RING_NOTIFICATIONS_COORDINATOR, ) +from .coordinator import RingDataCoordinator, RingNotificationsCoordinator _LOGGER = logging.getLogger(__name__) @@ -41,56 +29,29 @@ 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, CONF_TOKEN: token}, - ), - ).result() + ) + ) 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 ring_doorbell.AuthenticationError as err: - _LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") - raise ConfigEntryAuthFailed(err) from err + devices_coordinator = RingDataCoordinator(hass, ring) + notifications_coordinator = RingNotificationsCoordinator(hass, ring) + await devices_coordinator.async_config_entry_first_refresh() + await notifications_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { RING_API: ring, RING_DEVICES: ring.devices(), - RING_DEVICES_COORDINATOR: GlobalDataUpdater( - hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL - ), - RING_NOTIFICATIONS_COORDINATOR: GlobalDataUpdater( - hass, - "active dings", - entry, - ring, - "update_dings", - NOTIFICATIONS_SCAN_INTERVAL, - ), - RING_HISTORY_COORDINATOR: DeviceDataUpdater( - hass, - "history", - entry, - ring, - lambda device: device.history(limit=10), - HISTORY_SCAN_INTERVAL, - ), - RING_HEALTH_COORDINATOR: DeviceDataUpdater( - hass, - "health", - entry, - ring, - lambda device: device.update_health_data(), - HEALTH_SCAN_INTERVAL, - ), + RING_DEVICES_COORDINATOR: devices_coordinator, + RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -101,10 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_refresh_all(_: ServiceCall) -> None: """Refresh all ring data.""" for info in hass.data[DOMAIN].values(): - await info["device_data"].async_refresh_all() - await info["dings_data"].async_refresh_all() - await hass.async_add_executor_job(info["history_data"].refresh_all) - await hass.async_add_executor_job(info["health_data"].refresh_all) + await info[RING_DEVICES_COORDINATOR].async_refresh() + await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) @@ -133,173 +92,3 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a config entry from a device.""" return True - - -class GlobalDataUpdater: - """Data storage for single API endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - 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 = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.listeners: list[Callable[[], None]] = [] - self._unsub_interval = None - - @callback - def async_add_listener(self, update_callback): - """Listen for data updates.""" - # This is the first listener, set up interval. - if not self.listeners: - self._unsub_interval = async_track_time_interval( - self.hass, self.async_refresh_all, self.update_interval - ) - - self.listeners.append(update_callback) - - @callback - def async_remove_listener(self, update_callback): - """Remove data update.""" - self.listeners.remove(update_callback) - - if not self.listeners: - self._unsub_interval() - self._unsub_interval = None - - async def async_refresh_all(self, _now: int | None = None) -> None: - """Time to update.""" - if not self.listeners: - return - - try: - await self.hass.async_add_executor_job( - getattr(self.ring, self.update_method) - ) - 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 ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data", - self.data_type, - ) - return - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data: %s", - self.data_type, - err, - ) - return - - for update_callback in self.listeners: - update_callback() - - -class DeviceDataUpdater: - """Data storage for device data.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - 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 = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.devices: dict = {} - self._unsub_interval = None - - async def async_track_device(self, device, update_callback): - """Track a device.""" - if not self.devices: - self._unsub_interval = async_track_time_interval( - self.hass, self.refresh_all, self.update_interval - ) - - if device.device_id not in self.devices: - self.devices[device.device_id] = { - "device": device, - "update_callbacks": [update_callback], - "data": None, - } - # Store task so that other concurrent requests can wait for us to finish and - # data be available. - self.devices[device.device_id]["task"] = asyncio.current_task() - self.devices[device.device_id][ - "data" - ] = await self.hass.async_add_executor_job(self.update_method, device) - self.devices[device.device_id].pop("task") - else: - self.devices[device.device_id]["update_callbacks"].append(update_callback) - # If someone is currently fetching data as part of the initialization, wait for them - if "task" in self.devices[device.device_id]: - await self.devices[device.device_id]["task"] - - update_callback(self.devices[device.device_id]["data"]) - - @callback - def async_untrack_device(self, device, update_callback): - """Untrack a device.""" - self.devices[device.device_id]["update_callbacks"].remove(update_callback) - - if not self.devices[device.device_id]["update_callbacks"]: - self.devices.pop(device.device_id) - - if not self.devices: - self._unsub_interval() - self._unsub_interval = None - - def refresh_all(self, _=None): - """Refresh all registered devices.""" - for device_id, info in self.devices.items(): - try: - data = info["data"] = self.update_method(info["device"]) - 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 ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data for device %s", - self.data_type, - device_id, - ) - continue - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data for device %s: %s", - self.data_type, - device_id, - err, - ) - continue - - for update_callback in info["update_callbacks"]: - self.hass.loop.call_soon_threadsafe(update_callback, data) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 27eb82d34ee..a7e04f4cfb9 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR -from .entity import RingEntityMixin +from .coordinator import RingNotificationsCoordinator +from .entity import RingEntity @dataclass(frozen=True) @@ -55,9 +56,12 @@ async def async_setup_entry( """Set up the Ring binary sensors from a config entry.""" ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ][RING_NOTIFICATIONS_COORDINATOR] entities = [ - RingBinarySensor(config_entry.entry_id, ring, device, description) + RingBinarySensor(ring, device, notifications_coordinator, description) for device_type in ("doorbots", "authorized_doorbots", "stickup_cams") for description in BINARY_SENSOR_TYPES if device_type in description.category @@ -67,7 +71,7 @@ async def async_setup_entry( async_add_entities(entities) -class RingBinarySensor(RingEntityMixin, BinarySensorEntity): +class RingBinarySensor(RingEntity, BinarySensorEntity): """A binary sensor implementation for Ring device.""" _active_alert: dict[str, Any] | None = None @@ -75,38 +79,26 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): def __init__( self, - config_entry_id, ring, device, + coordinator, description: RingBinarySensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__( + device, + coordinator, + ) self.entity_description = description self._ring = ring self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - 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[RING_NOTIFICATIONS_COORDINATOR].async_remove_listener( - self._dings_update_callback - ) - @callback - def _dings_update_callback(self): + def _handle_coordinator_update(self, _=None): """Call update method.""" self._update_alert() - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _update_alert(self): diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 196d34600d1..265d7102b91 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta from itertools import chain import logging +from typing import Optional from haffmpeg.camera import CameraMjpeg import requests @@ -16,8 +17,9 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -31,6 +33,9 @@ async def async_setup_entry( ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] @@ -40,19 +45,20 @@ async def async_setup_entry( if not camera.has_subscription: continue - cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) + cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager)) async_add_entities(cams) -class RingCam(RingEntityMixin, Camera): +class RingCam(RingEntity, Camera): """An implementation of a Ring Door Bell camera.""" _attr_name = None - def __init__(self, config_entry_id, ffmpeg_manager, device): + def __init__(self, device, coordinator, ffmpeg_manager): """Initialize a Ring Door Bell camera.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) + Camera.__init__(self) self._ffmpeg_manager = ffmpeg_manager self._last_event = None @@ -62,25 +68,12 @@ class RingCam(RingEntityMixin, Camera): self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._attr_unique_id = device.id - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" + history_data: Optional[list] + if not (history_data := self._get_coordinator_history()): + return if history_data: self._last_event = history_data[0] self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 10d517ab4a3..f0e0c63d778 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -15,25 +15,21 @@ DEFAULT_ENTITY_NAMESPACE = "ring" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.CAMERA, Platform.LIGHT, Platform.SENSOR, - Platform.SWITCH, - Platform.CAMERA, Platform.SIREN, + Platform.SWITCH, ] -DEVICES_SCAN_INTERVAL = timedelta(minutes=1) +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/coordinator.py b/homeassistant/components/ring/coordinator.py new file mode 100644 index 00000000000..5b6412caffa --- /dev/null +++ b/homeassistant/components/ring/coordinator.py @@ -0,0 +1,119 @@ +"""Data coordinators for the ring integration.""" +from asyncio import TaskGroup +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Optional + +import ring_doorbell +from ring_doorbell.generic import RingGeneric + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def _call_api( + hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = "" +): + try: + return await hass.async_add_executor_job(target, *args) + except ring_doorbell.AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ring_doorbell.RingTimeout as err: + raise UpdateFailed( + f"Timeout communicating with API{msg_suffix}: {err}" + ) from err + except ring_doorbell.RingError as err: + raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err + + +@dataclass +class RingDeviceData: + """RingDeviceData.""" + + device: RingGeneric + history: Optional[list] = None + + +class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): + """Base class for device coordinators.""" + + def __init__( + self, + hass: HomeAssistant, + ring_api: ring_doorbell.Ring, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + name="devices", + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + self.first_call: bool = True + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + update_method: str = "update_data" if self.first_call else "update_devices" + await _call_api(self.hass, getattr(self.ring_api, update_method)) + self.first_call = False + data: dict[str, RingDeviceData] = {} + devices: dict[str : list[RingGeneric]] = self.ring_api.devices() + subscribed_device_ids = set(self.async_contexts()) + for device_type in devices: + for device in devices[device_type]: + # Don't update all devices in the ring api, only those that set + # their device id as context when they subscribed. + if device.id in subscribed_device_ids: + data[device.id] = RingDeviceData(device=device) + try: + history_task = None + async with TaskGroup() as tg: + if hasattr(device, "history"): + history_task = tg.create_task( + _call_api( + self.hass, + lambda device: device.history(limit=10), + device, + msg_suffix=f" for device {device.name}", # device_id is the mac + ) + ) + tg.create_task( + _call_api( + self.hass, + device.update_health_data, + msg_suffix=f" for device {device.name}", + ) + ) + if history_task: + data[device.id].history = history_task.result() + except ExceptionGroup as eg: + raise eg.exceptions[0] + + return data + + +class RingNotificationsCoordinator(DataUpdateCoordinator[None]): + """Global notifications coordinator.""" + + def __init__(self, hass: HomeAssistant, ring_api: ring_doorbell.Ring) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name="active dings", + update_interval=NOTIFICATIONS_SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + await _call_api(self.hass, self.ring_api.update_dings) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 4896ea2db8b..78f0c8e468e 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,49 +1,71 @@ """Base class for Ring entity.""" +from typing import TypeVar + +from ring_doorbell.generic import RingGeneric + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, RING_DEVICES_COORDINATOR +from .const import ATTRIBUTION, DOMAIN +from .coordinator import ( + RingDataCoordinator, + RingDeviceData, + RingNotificationsCoordinator, +) + +_RingCoordinatorT = TypeVar( + "_RingCoordinatorT", + bound=(RingDataCoordinator | RingNotificationsCoordinator), +) -class RingEntityMixin(Entity): +class RingEntity(CoordinatorEntity[_RingCoordinatorT]): """Base implementation for Ring device.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, config_entry_id, device): + def __init__( + self, + device: RingGeneric, + coordinator: _RingCoordinatorT, + ) -> None: """Initialize a sensor for Ring device.""" - super().__init__() - self._config_entry_id = config_entry_id + super().__init__(coordinator, context=device.id) self._device = device self._attr_extra_state_attributes = {} self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_id)}, + identifiers={(DOMAIN, device.device_id)}, # device_id is the mac manufacturer="Ring", model=device.model, name=device.name, ) - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( - self._update_callback - ) + def _get_coordinator_device_data(self) -> RingDeviceData | None: + if (data := self.coordinator.data) and ( + device_data := data.get(self._device.id) + ): + return device_data + return None - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( - self._update_callback - ) + def _get_coordinator_device(self) -> RingGeneric | None: + if (device_data := self._get_coordinator_device_data()) and ( + device := device_data.device + ): + return device + return None + + def _get_coordinator_history(self) -> list | None: + if (device_data := self._get_coordinator_device_data()) and ( + history := device_data.history + ): + return history + return None @callback - def _update_callback(self) -> None: - """Call update method.""" - self.async_write_ha_state() - - @property - def ring_objects(self): - """Return the Ring API objects.""" - return self.hass.data[DOMAIN][self._config_entry_id] + def _handle_coordinator_update(self) -> None: + if device := self._get_coordinator_device(): + self._device = device + super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 7830b2547a5..73ec8349384 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -4,6 +4,8 @@ import logging from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -35,38 +38,42 @@ async def async_setup_entry( ) -> None: """Create the lights for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] lights = [] for device in devices["stickup_cams"]: if device.has_capability("light"): - lights.append(RingLight(config_entry.entry_id, device)) + lights.append(RingLight(device, devices_coordinator)) async_add_entities(lights) -class RingLight(RingEntityMixin, LightEntity): +class RingLight(RingEntity, LightEntity): """Creates a switch to turn the ring cameras light on and off.""" _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator) -> None: """Initialize the light.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._attr_unique_id = device.id self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - - self._attr_is_on = self._device.lights == ON_STATE - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.lights == ON_STATE + super()._handle_coordinator_update() def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" @@ -78,7 +85,7 @@ class RingLight(RingEntityMixin, LightEntity): self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_write_ha_state() + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the light on for 30 seconds.""" diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 85cab6f1763..0390db640e5 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[listen]==0.8.5"] + "requirements": ["ring-doorbell[listen]==0.8.7"] } diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index a596d413ac7..356eb1c2b9b 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -4,23 +4,25 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from ring_doorbell.generic import RingGeneric + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DOMAIN, - RING_DEVICES, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, -) -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity async def async_setup_entry( @@ -30,9 +32,12 @@ async def async_setup_entry( ) -> None: """Set up a sensor for a Ring device.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] entities = [ - description.cls(config_entry.entry_id, device, description) + description.cls(device, devices_coordinator, description) for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams") for description in SENSOR_TYPES if device_type in description.category @@ -43,19 +48,19 @@ async def async_setup_entry( async_add_entities(entities) -class RingSensor(RingEntityMixin, SensorEntity): +class RingSensor(RingEntity, SensorEntity): """A sensor implementation for Ring device.""" entity_description: RingSensorEntityDescription def __init__( self, - config_entry_id, - device, + device: RingGeneric, + coordinator: RingDataCoordinator, description: RingSensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{device.id}-{description.key}" @@ -76,27 +81,6 @@ class HealthDataRingSensor(RingSensor): # These sensors are data hungry and not useful. Disable by default. _attr_entity_registry_enabled_default = False - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HEALTH_COORDINATOR].async_track_device( - self._device, self._health_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HEALTH_COORDINATOR].async_untrack_device( - self._device, self._health_update_callback - ) - - @callback - def _health_update_callback(self, _health_data): - """Call update method.""" - self.async_write_ha_state() - @property def native_value(self): """Return the state of the sensor.""" @@ -113,26 +97,10 @@ class HistoryRingSensor(RingSensor): _latest_event: dict[str, Any] | None = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" - if not history_data: + if not (history_data := self._get_coordinator_history()): return kind = self.entity_description.kind @@ -149,7 +117,7 @@ class HistoryRingSensor(RingSensor): return self._latest_event = found - self.async_write_ha_state() + super()._handle_coordinator_update() @property def native_value(self): @@ -194,6 +162,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( category=["doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, cls=RingSensor, ), RingSensorEntityDescription( @@ -234,6 +203,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( translation_key="wifi_signal_category", category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, cls=HealthDataRingSensor, ), RingSensorEntityDescription( @@ -243,6 +213,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, icon="mdi:wifi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, cls=HealthDataRingSensor, ), ) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7daf7bd69ca..0844f650e57 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -3,14 +3,16 @@ import logging from typing import Any from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING +from ring_doorbell.generic import RingGeneric from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -22,24 +24,27 @@ async def async_setup_entry( ) -> None: """Create the sirens for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] sirens = [] for device in devices["chimes"]: - sirens.append(RingChimeSiren(config_entry, device)) + sirens.append(RingChimeSiren(device, coordinator)) async_add_entities(sirens) -class RingChimeSiren(RingEntityMixin, SirenEntity): +class RingChimeSiren(RingEntity, SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" _attr_available_tones = CHIME_TEST_SOUND_KINDS _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_translation_key = "siren" - def __init__(self, config_entry: ConfigEntry, device) -> None: + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize a Ring Chime siren.""" - super().__init__(config_entry.entry_id, device) + super().__init__(device, coordinator) # Entity class attributes self._attr_unique_id = f"{self._device.id}-siren" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 074dfee9bd6..1f06f06e32e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,6 +4,8 @@ import logging from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -34,21 +37,26 @@ async def async_setup_entry( ) -> None: """Create the switches for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] switches = [] for device in devices["stickup_cams"]: if device.has_capability("siren"): - switches.append(SirenSwitch(config_entry.entry_id, device)) + switches.append(SirenSwitch(device, coordinator)) async_add_entities(switches) -class BaseRingSwitch(RingEntityMixin, SwitchEntity): +class BaseRingSwitch(RingEntity, SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" - def __init__(self, config_entry_id, device, device_type): + def __init__( + self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str + ) -> None: """Initialize the switch.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._device_type = device_type self._attr_unique_id = f"{self._device.id}-{self._device_type}" @@ -59,20 +67,23 @@ class SirenSwitch(BaseRingSwitch): _attr_translation_key = "siren" _attr_icon = SIREN_ICON - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize the switch for a device with a siren.""" - super().__init__(config_entry_id, device, "siren") + super().__init__(device, coordinator, "siren") self._no_updates_until = dt_util.utcnow() self._attr_is_on = device.siren > 0 @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - self._attr_is_on = self._device.siren > 0 - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.siren > 0 + super()._handle_coordinator_update() def _set_switch(self, new_state): """Update switch state, and causes Home Assistant to correctly update.""" diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 9c62447ee04..d1e1c4f430c 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -1,4 +1,6 @@ """The Risco integration.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass, field from datetime import timedelta @@ -195,7 +197,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): +class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching risco data.""" def __init__( @@ -219,7 +221,7 @@ class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): raise UpdateFailed(error) from error -class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): +class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching risco data.""" def __init__( diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index a72efe1629c..8a233d0b5fe 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -6,6 +6,7 @@ import logging from typing import Any from pyrisco.common import Partition +from pyrisco.local.partition import Partition as LocalPartition from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -132,7 +133,7 @@ class RiscoAlarm(AlarmControlPanelEntity): return None - def _validate_code(self, code): + def _validate_code(self, code: str | None) -> bool: """Validate given code.""" return code == self._code @@ -159,7 +160,7 @@ class RiscoAlarm(AlarmControlPanelEntity): """Send arm custom bypass command.""" await self._arm(STATE_ALARM_ARMED_CUSTOM_BYPASS, code) - async def _arm(self, mode, code): + async def _arm(self, mode: str, code: str | None) -> None: if self.code_arm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for %s", mode) return @@ -205,7 +206,7 @@ class RiscoCloudAlarm(RiscoAlarm, RiscoCloudEntity): def _get_data_from_coordinator(self) -> None: self._partition = self.coordinator.data.partitions[self._partition_id] - async def _call_alarm_method(self, method, *args): + async def _call_alarm_method(self, method: str, *args: Any) -> None: alarm = await getattr(self._risco, method)(self._partition_id, *args) self._partition = alarm.partitions[self._partition_id] self.async_write_ha_state() @@ -220,7 +221,7 @@ class RiscoLocalAlarm(RiscoAlarm): self, system_id: str, partition_id: int, - partition: Partition, + partition: LocalPartition, partition_updates: dict[int, Callable[[], Any]], code: str, options: dict[str, Any], diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index f60b0bf3c35..ea7153b2aee 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pyrisco.common import Zone +from pyrisco.cloud.zone import Zone as CloudZone +from pyrisco.local.zone import Zone as LocalZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -53,7 +54,7 @@ class RiscoCloudBinarySensor(RiscoCloudZoneEntity, BinarySensorEntity): _attr_name = None def __init__( - self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone + self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: CloudZone ) -> None: """Init the zone.""" super().__init__(coordinator=coordinator, suffix="", zone_id=zone_id, zone=zone) @@ -70,7 +71,7 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION _attr_name = None - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__(system_id=system_id, suffix="", zone_id=zone_id, zone=zone) @@ -93,7 +94,7 @@ class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_translation_key = "alarmed" - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__( system_id=system_id, @@ -113,7 +114,7 @@ class RiscoLocalArmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_translation_key = "armed" - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__( system_id=system_id, diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index ef96714742d..61a452a7ecb 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -63,7 +63,9 @@ HA_STATES = [ ] -async def validate_cloud_input(hass: core.HomeAssistant, data) -> dict[str, str]: +async def validate_cloud_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect to Risco Cloud. Data has the keys from CLOUD_SCHEMA with values provided by the user. @@ -124,16 +126,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(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 the initial step.""" return self.async_show_menu( step_id="user", menu_options=["cloud", "local"], ) - async def async_step_cloud(self, user_input=None): + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a cloud based alarm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: if not self._reauth_entry: await self.async_set_unique_id(user_input[CONF_USERNAME]) @@ -168,14 +174,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_entry = await self.async_set_unique_id(entry_data[CONF_USERNAME]) 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: """Configure a local based alarm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: info = await validate_local_input(self.hass, user_input) - except CannotConnectError: - _LOGGER.debug("Cannot connect", exc_info=1) + except CannotConnectError as ex: + _LOGGER.debug("Cannot connect", exc_info=ex) errors["base"] = "cannot_connect" except UnauthorizedError: errors["base"] = "invalid_auth" @@ -208,7 +216,7 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} - def _options_schema(self): + def _options_schema(self) -> vol.Schema: return vol.Schema( { vol.Required( @@ -224,7 +232,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): } ) - 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: self._data = {**self._data, **user_input} @@ -232,7 +242,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="init", data_schema=self._options_schema()) - async def async_step_risco_to_ha(self, user_input=None): + async def async_step_risco_to_ha( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Map Risco states to HA states.""" if user_input is not None: self._data[CONF_RISCO_STATES_TO_HA] = user_input @@ -250,7 +262,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="risco_to_ha", data_schema=options) - async def async_step_ha_to_risco(self, user_input=None): + async def async_step_ha_to_risco( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Map HA states to Risco states.""" if user_input is not None: self._data[CONF_HA_STATES_TO_RISCO] = user_input diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index e522c29ce19..ac3c04cfc2e 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import Any -from pyrisco.common import Zone +from pyrisco import RiscoCloud +from pyrisco.cloud.zone import Zone as CloudZone +from pyrisco.local.zone import Zone as LocalZone from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,7 +16,7 @@ from . import RiscoDataUpdateCoordinator, zone_update_signal from .const import DOMAIN -def zone_unique_id(risco, zone_id: int) -> str: +def zone_unique_id(risco: RiscoCloud, zone_id: int) -> str: """Return unique id for a cloud zone.""" return f"{risco.site_uuid}_zone_{zone_id}" @@ -36,7 +38,7 @@ class RiscoCloudEntity(CoordinatorEntity[RiscoDataUpdateCoordinator]): self.async_write_ha_state() @property - def _risco(self): + def _risco(self) -> RiscoCloud: """Return the Risco API object.""" return self.coordinator.risco @@ -52,7 +54,7 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): coordinator: RiscoDataUpdateCoordinator, suffix: str, zone_id: int, - zone: Zone, + zone: CloudZone, **kwargs: Any, ) -> None: """Init the zone.""" @@ -84,7 +86,7 @@ class RiscoLocalZoneEntity(Entity): system_id: str, suffix: str, zone_id: int, - zone: Zone, + zone: LocalZone, **kwargs: Any, ) -> None: """Init the zone.""" diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index 1d60ea4d7c2..138c08c18f6 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -2,8 +2,11 @@ from __future__ import annotations from collections.abc import Collection, Mapping +from datetime import datetime from typing import Any +from pyrisco.cloud.event import Event + from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -66,22 +69,23 @@ async def async_setup_entry( class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEntity): """Sensor for Risco events.""" + _entity_registry: er.EntityRegistry + def __init__( self, coordinator: RiscoEventsDataUpdateCoordinator, category_id: int | None, - excludes: Collection[int] | None, + excludes: Collection[int], name: str, entry_id: str, ) -> None: """Initialize sensor.""" super().__init__(coordinator) - self._event = None + self._event: Event | None = None self._category_id = category_id self._excludes = excludes self._name = name self._entry_id = entry_id - self._entity_registry: er.EntityRegistry | None = None self._attr_unique_id = f"events_{name}_{self.coordinator.risco.site_uuid}" self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP @@ -91,7 +95,7 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt await super().async_added_to_hass() self._entity_registry = er.async_get(self.hass) - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: events = self.coordinator.data for event in reversed(events): if event.category_id in self._excludes: @@ -103,14 +107,14 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt self.async_write_ha_state() @property - def native_value(self): + def native_value(self) -> datetime | None: """Value of sensor.""" if self._event is None: return None - return dt_util.parse_datetime(self._event.time).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ) + if res := dt_util.parse_datetime(self._event.time): + return res.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + return None @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 9b34479f8a2..d22b2bb2192 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -1,6 +1,8 @@ """Support for bypassing Risco alarm zones.""" from __future__ import annotations +from typing import Any + from pyrisco.common import Zone from homeassistant.components.switch import SwitchEntity @@ -58,11 +60,11 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Return true if the zone is bypassed.""" return self._zone.bypassed - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._bypass(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._bypass(False) @@ -92,11 +94,11 @@ class RiscoLocalSwitch(RiscoLocalZoneEntity, SwitchEntity): """Return true if the zone is bypassed.""" return self._zone.bypassed - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._bypass(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._bypass(False) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index ff49b352c18..0b4dfa29e78 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -8,9 +8,9 @@ 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, HomeDataProduct, UserData +from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME @@ -35,9 +35,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: home_data = await api_client.get_home_data(user_data) except RoborockInvalidCredentials as err: - raise ConfigEntryAuthFailed("Invalid credentials.") from err + raise ConfigEntryAuthFailed( + "Invalid credentials", + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from err except RoborockException as err: - raise ConfigEntryNotReady("Failed getting Roborock home_data.") from err + raise ConfigEntryNotReady( + "Failed to get Roborock home data", + translation_domain=DOMAIN, + translation_key="home_data_fail", + ) from err _LOGGER.debug("Got home data %s", home_data) device_map: dict[str, HomeDataDevice] = { device.duid: device for device in home_data.devices + home_data.received_devices @@ -57,7 +65,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(coord, RoborockDataUpdateCoordinator) ] if len(valid_coordinators) == 0: - raise ConfigEntryNotReady("No coordinators were able to successfully setup.") + raise ConfigEntryNotReady( + "No devices were able to successfully setup", + translation_domain=DOMAIN, + translation_key="no_coordinators", + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { coordinator.roborock_device_info.device.duid: coordinator for coordinator in valid_coordinators diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 03e1eabe45a..e4e65288832 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -40,7 +40,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="dry_status", translation_key="mop_drying_status", - icon="mdi:heat-wave", device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, @@ -48,7 +47,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_box_carriage_status", translation_key="mop_attached", - icon="mdi:square-rounded", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_carriage_status, @@ -56,7 +54,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_box_status", translation_key="water_box_attached", - icon="mdi:water", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_status, @@ -64,7 +61,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_shortage", translation_key="water_shortage", - icon="mdi:water", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, @@ -72,7 +68,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="in_cleaning", translation_key="in_cleaning", - icon="mdi:vacuum", device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.in_cleaning, diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 7744c5988d8..e64b39c2383 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -35,7 +35,6 @@ class RoborockButtonDescription( CONSUMABLE_BUTTON_DESCRIPTIONS = [ RoborockButtonDescription( key="reset_sensor_consumable", - icon="mdi:eye-outline", translation_key="reset_sensor_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["sensor_dirty_time"], @@ -44,7 +43,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_air_filter_consumable", - icon="mdi:air-filter", translation_key="reset_air_filter_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["filter_work_time"], @@ -53,7 +51,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_side_brush_consumable", - icon="mdi:brush", translation_key="reset_side_brush_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["side_brush_work_time"], @@ -62,7 +59,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_main_brush_consumable", - icon="mdi:brush", translation_key="reset_main_brush_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["main_brush_work_time"], diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 201631f0825..82c513a1b97 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -5,7 +5,6 @@ from collections.abc import Mapping import logging from typing import Any -from roborock.api import RoborockApiClient from roborock.containers import UserData from roborock.exceptions import ( RoborockAccountDoesNotExist, @@ -14,6 +13,7 @@ from roborock.exceptions import ( RoborockInvalidEmail, RoborockUrlException, ) +from roborock.web_api import RoborockApiClient import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index d7a3a9229f5..f163c9620d1 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -9,8 +9,8 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" PLATFORMS = [ - Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 71376dd600e..17531f6c627 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator +from .const import DOMAIN class RoborockEntity(Entity): @@ -48,10 +49,18 @@ class RoborockEntity(Entity): try: response: dict = await self._api.send_command(command, params) except RoborockException as err: + if isinstance(command, RoborockCommand): + command_name = command.name + else: + command_name = command raise HomeAssistantError( - f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" + f"Error while calling {command}", + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": command_name, + }, ) from err - return response diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json new file mode 100644 index 00000000000..43e7f185433 --- /dev/null +++ b/homeassistant/components/roborock/icons.json @@ -0,0 +1,109 @@ +{ + "entity": { + "binary_sensor": { + "mop_drying_status": { + "default": "mdi:heat-wave" + }, + "mop_attached": { + "default": "mdi:square-rounded" + }, + "water_box_attached": { + "default": "mdi:water" + }, + "water_shortage": { + "default": "mdi:water" + }, + "in_cleaning": { + "default": "mdi:vacuum" + } + }, + "button": { + "reset_sensor_consumable": { + "default": "mdi:eye-outline" + }, + "reset_air_filter_consumable": { + "default": "mdi:air-filter" + }, + "reset_side_brush_consumable": { + "default": "mdi:brush" + }, + "reset_main_brush_consumable": { + "default": "mdi:brush" + } + }, + "number": { + "volume": { + "default": "mdi:volume-source" + } + }, + "sensor": { + "main_brush_time_left": { + "default": "mdi:brush" + }, + "side_brush_time_left": { + "default": "mdi:brush" + }, + "filter_time_left": { + "default": "mdi:air-filter" + }, + "sensor_time_left": { + "default": "mdi:eye-outline" + }, + "total_cleaning_time": { + "default": "mdi:history" + }, + "status": { + "default": "mdi:information-outline" + }, + "cleaning_area": { + "default": "mdi:texture-box" + }, + "total_cleaning_area": { + "default": "mdi:texture-box" + }, + "vacuum_error": { + "default": "mdi:alert-circle" + }, + "last_clean_start": { + "default": "mdi:clock-time-twelve" + }, + "last_clean_end": { + "default": "mdi:clock-time-twelve" + }, + "clean_percent": { + "default": "mdi:progress-check" + }, + "dock_error": { + "default": "mdi:garage-open" + } + }, + "switch": { + "child_lock": { + "default": "mdi:account-lock" + }, + "status_indicator": { + "default": "mdi:alarm-light-outline" + }, + "dnd_switch": { + "default": "mdi:bell-cancel" + }, + "off_peak_switch": { + "default": "mdi:power-plug" + } + }, + "time": { + "dnd_start_time": { + "default": "mdi:bell-cancel" + }, + "dnd_end_time": { + "default": "mdi:bell-ring" + }, + "off_peak_start": { + "default": "mdi:power-plug" + }, + "off_peak_end": { + "default": "mdi:power-plug-off" + } + } + } +} diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 5e61bb1d408..b2a14b57819 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -109,7 +109,11 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): """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.") + raise HomeAssistantError( + "Something went wrong creating the map", + translation_domain=DOMAIN, + translation_key="map_failure", + ) img_byte_arr = io.BytesIO() parsed_map.image.data.save(img_byte_arr, format="PNG") return img_byte_arr.getvalue() diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index c149b9fcf7f..ddb65c3187c 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.38.0", + "python-roborock==0.39.1", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 8957c487a64..2218e5ec2ce 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -44,7 +44,6 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ RoborockNumberDescription( key="volume", translation_key="volume", - icon="mdi:volume-source", native_min_value=0, native_max_value=100, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index e3cea00476f..d5258879acb 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -65,7 +65,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="main_brush_time_left", - icon="mdi:brush", device_class=SensorDeviceClass.DURATION, translation_key="main_brush_time_left", value_fn=lambda data: data.consumable.main_brush_time_left, @@ -75,7 +74,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="side_brush_time_left", - icon="mdi:brush", device_class=SensorDeviceClass.DURATION, translation_key="side_brush_time_left", value_fn=lambda data: data.consumable.side_brush_time_left, @@ -85,7 +83,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="filter_time_left", - icon="mdi:air-filter", device_class=SensorDeviceClass.DURATION, translation_key="filter_time_left", value_fn=lambda data: data.consumable.filter_time_left, @@ -95,7 +92,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="sensor_time_left", - icon="mdi:eye-outline", device_class=SensorDeviceClass.DURATION, translation_key="sensor_time_left", value_fn=lambda data: data.consumable.sensor_time_left, @@ -113,14 +109,12 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=UnitOfTime.SECONDS, key="total_cleaning_time", translation_key="total_cleaning_time", - icon="mdi:history", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( key="status", - icon="mdi:information-outline", device_class=SensorDeviceClass.ENUM, translation_key="status", value_fn=lambda data: data.status.state_name, @@ -130,7 +124,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="cleaning_area", - icon="mdi:texture-box", translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, @@ -138,7 +131,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="total_cleaning_area", - icon="mdi:texture-box", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, @@ -146,7 +138,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="vacuum_error", - icon="mdi:alert-circle", translation_key="vacuum_error", device_class=SensorDeviceClass.ENUM, value_fn=lambda data: data.status.error_code_name, @@ -165,7 +156,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( key="last_clean_start", translation_key="last_clean_start", - icon="mdi:clock-time-twelve", value_fn=lambda data: data.last_clean_record.begin_datetime if data.last_clean_record is not None else None, @@ -175,7 +165,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( key="last_clean_end", translation_key="last_clean_end", - icon="mdi:clock-time-twelve", value_fn=lambda data: data.last_clean_record.end_datetime if data.last_clean_record is not None else None, @@ -185,7 +174,6 @@ SENSOR_DESCRIPTIONS = [ # Only available on some newer models RoborockSensorDescription( key="clean_percent", - icon="mdi:progress-check", translation_key="clean_percent", value_fn=lambda data: data.status.clean_percent, entity_category=EntityCategory.DIAGNOSTIC, @@ -194,7 +182,6 @@ SENSOR_DESCRIPTIONS = [ # Only available with more than just the basic dock RoborockSensorDescription( key="dock_error", - icon="mdi:garage-open", translation_key="dock_error", value_fn=_dock_error_value_fn, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 67660816de7..7c457a1935b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -136,7 +136,11 @@ "washing_the_mop": "Washing the mop", "going_to_wash_the_mop": "Going to wash the mop", "charging_complete": "Charging complete", - "device_offline": "Device offline" + "device_offline": "Device offline", + "unknown": "Unknown", + "locked": "Locked", + "air_drying_stopping": "Air drying stopping", + "egg_attack": "Cupid mode" } }, "total_cleaning_time": { @@ -174,7 +178,25 @@ "filter_blocked": "Filter blocked", "invisible_wall_detected": "Invisible wall detected", "cannot_cross_carpet": "Cannot cross carpet", - "internal_error": "Internal error" + "internal_error": "Internal error", + "strainer_error": "Filter is wet or blocked", + "compass_error": "Strong magnetic field detected", + "dock": "Dock not connected to power", + "visual_sensor": "Camera error", + "light_touch": "Wall sensor error", + "collect_dust_error_3": "Clean auto-empty dock", + "collect_dust_error_4": "Auto empty dock voltage error", + "mopping_roller_1": "Wash roller may be jammed", + "mopping_roller_error_2": "Wash roller not lowered properly", + "clear_water_box_hoare": "Check the clean water tank", + "dirty_water_box_hoare": "Check the dirty water tank", + "sink_strainer_hoare": "Reinstall the water filter", + "clear_water_box_exception": "Clean water tank empty", + "clear_brush_exception": "Check that the water filter has been correctly installed", + "clear_brush_exception_2": "Positioning button error", + "filter_screen_exception": "Clean the dock water filter", + "mopping_roller_2": "[%key:component::roborock::entity::sensor::vacuum_error::state::mopping_roller_1%]", + "temperature_protection": "Unit temperature protection" } } }, @@ -255,17 +277,21 @@ } } }, - "issues": { - "service_deprecation_start_pause": { - "title": "Roborock vacuum support for vacuum.start_pause is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::roborock::issues::service_deprecation_start_pause::title%]", - "description": "Roborock vacuum support for the vacuum.start_pause service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.pause or vacuum.start and select submit below to mark this issue as resolved." - } - } - } + "exceptions": { + "command_failed": { + "message": "Error while calling {command}" + }, + "home_data_fail": { + "message": "Failed to get Roborock home data" + }, + "invalid_credentials": { + "message": "Invalid credentials." + }, + "map_failure": { + "message": "Something went wrong creating the map" + }, + "no_coordinators": { + "message": "No devices were able to successfully setup" } } } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 37e8488dd22..acd3e2613af 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -52,7 +52,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="lock_status", key="child_lock", translation_key="child_lock", - icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -63,7 +62,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="status", key="status_indicator", translation_key="status_indicator", - icon="mdi:alarm-light-outline", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -81,7 +79,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="enabled", key="dnd_switch", translation_key="dnd_switch", - icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -99,7 +96,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="enabled", key="off_peak_switch", translation_key="off_peak_switch", - icon="mdi:power-plug", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 7a8d21fc0f1..71dee773fa4 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -46,7 +46,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="dnd_start_time", translation_key="dnd_start_time", - icon="mdi:bell-cancel", cache_key=CacheableAttribute.dnd_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -64,7 +63,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="dnd_end_time", translation_key="dnd_end_time", - icon="mdi:bell-ring", cache_key=CacheableAttribute.dnd_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -82,7 +80,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="off_peak_start", translation_key="off_peak_start", - icon="mdi:power-plug", cache_key=CacheableAttribute.valley_electricity_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -101,7 +98,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="off_peak_end", translation_key="off_peak_end", - icon="mdi:power-plug-off", cache_key=CacheableAttribute.valley_electricity_timer, update_value=lambda cache, desired_time: cache.update_value( [ diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index c8b43e74efd..3b8f0e756b7 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -17,7 +17,6 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -149,23 +148,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): [self._device_status.get_fan_speed_code(fan_speed)], ) - async def async_start_pause(self) -> None: - """Start, pause or resume the cleaning task.""" - if self.state == STATE_CLEANING: - await self.async_pause() - else: - await self.async_start() - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_start_pause", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_start_pause", - ) - async def async_send_command( self, command: str, diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 6fe70a3ab65..4e255fcf86c 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.18.1"], + "requirements": ["rokuecp==0.19.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py new file mode 100644 index 00000000000..352f5f3715a --- /dev/null +++ b/homeassistant/components/romy/__init__.py @@ -0,0 +1,42 @@ +"""ROMY Integration.""" + +import romy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import RomyVacuumCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Initialize the ROMY platform via config entry.""" + + new_romy = await romy.create_romy( + config_entry.data[CONF_HOST], config_entry.data.get(CONF_PASSWORD, "") + ) + + coordinator = RomyVacuumCoordinator(hass, new_romy) + 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) + + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + LOGGER.debug("update_listener") + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py new file mode 100644 index 00000000000..6bc96c9878c --- /dev/null +++ b/homeassistant/components/romy/config_flow.py @@ -0,0 +1,148 @@ +"""Config flow for ROMY integration.""" +from __future__ import annotations + +import romy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER + + +class RomyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for ROMY.""" + + VERSION = 1 + + def __init__(self) -> None: + """Handle a config flow for ROMY.""" + self.host: str = "" + self.password: str = "" + self.robot_name_given_by_user: str = "" + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + + if user_input: + self.host = user_input[CONF_HOST] + + new_romy = await romy.create_romy(self.host, "") + + if not new_romy.is_initialized: + errors[CONF_HOST] = "cannot_connect" + else: + await self.async_set_unique_id(new_romy.unique_id) + self._abort_if_unique_id_configured() + + self.robot_name_given_by_user = new_romy.user_name + + if not new_romy.is_unlocked: + return await self.async_step_password() + return await self._async_step_finish_config() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + }, + ), + errors=errors, + ) + + async def async_step_password( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Unlock the robots local http interface with password.""" + errors: dict[str, str] = {} + + if user_input: + self.password = user_input[CONF_PASSWORD] + new_romy = await romy.create_romy(self.host, self.password) + + if not new_romy.is_initialized: + errors[CONF_PASSWORD] = "cannot_connect" + elif not new_romy.is_unlocked: + errors[CONF_PASSWORD] = "invalid_auth" + + if not errors: + return await self._async_step_finish_config() + + return self.async_show_form( + step_id="password", + data_schema=vol.Schema( + {vol.Required(CONF_PASSWORD): vol.All(cv.string, vol.Length(8))}, + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + LOGGER.debug("Zeroconf discovery_info: %s", discovery_info) + + # connect and gather information from your ROMY + self.host = discovery_info.host + LOGGER.debug("ZeroConf Host: %s", self.host) + + new_discovered_romy = await romy.create_romy(self.host, "") + + self.robot_name_given_by_user = new_discovered_romy.user_name + LOGGER.debug("ZeroConf Name: %s", self.robot_name_given_by_user) + + # get unique id and stop discovery if robot is already added + unique_id = new_discovered_romy.unique_id + LOGGER.debug("ZeroConf Unique_id: %s", unique_id) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.context.update( + { + "title_placeholders": { + "name": f"{self.robot_name_given_by_user} ({self.host} / {unique_id})" + }, + "configuration_url": f"http://{self.host}:{new_discovered_romy.port}", + } + ) + + # if robot got already unlocked with password add it directly + if not new_discovered_romy.is_initialized: + return self.async_abort(reason="cannot_connect") + + if new_discovered_romy.is_unlocked: + return await self.async_step_zeroconf_confirm() + + return await self.async_step_password() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, str] | 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={ + "name": self.robot_name_given_by_user, + "host": self.host, + }, + ) + return await self._async_step_finish_config() + + async def _async_step_finish_config(self) -> FlowResult: + """Finish the configuration setup.""" + return self.async_create_entry( + title=self.robot_name_given_by_user, + data={ + CONF_HOST: self.host, + CONF_PASSWORD: self.password, + }, + ) diff --git a/homeassistant/components/romy/const.py b/homeassistant/components/romy/const.py new file mode 100644 index 00000000000..5d42380902b --- /dev/null +++ b/homeassistant/components/romy/const.py @@ -0,0 +1,11 @@ +"""Constants for the ROMY integration.""" + +from datetime import timedelta +import logging + +from homeassistant.const import Platform + +DOMAIN = "romy" +PLATFORMS = [Platform.VACUUM] +UPDATE_INTERVAL = timedelta(seconds=5) +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py new file mode 100644 index 00000000000..5868eae70e2 --- /dev/null +++ b/homeassistant/components/romy/coordinator.py @@ -0,0 +1,22 @@ +"""ROMY coordinator.""" + +from romy import RomyRobot + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + + +class RomyVacuumCoordinator(DataUpdateCoordinator[None]): + """ROMY Vacuum Coordinator.""" + + def __init__(self, hass: HomeAssistant, romy: RomyRobot) -> None: + """Initialize.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.hass = hass + self.romy = romy + + async def _async_update_data(self) -> None: + """Update ROMY Vacuum Cleaner data.""" + await self.romy.async_update() diff --git a/homeassistant/components/romy/manifest.json b/homeassistant/components/romy/manifest.json new file mode 100644 index 00000000000..1257c2d1d60 --- /dev/null +++ b/homeassistant/components/romy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "romy", + "name": "ROMY Vacuum Cleaner", + "codeowners": ["@xeniter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/romy", + "iot_class": "local_polling", + "requirements": ["romy==0.0.7"], + "zeroconf": ["_aicu-http._tcp.local."] +} diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json new file mode 100644 index 00000000000..26dc60a2e84 --- /dev/null +++ b/homeassistant/components/romy/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "flow_title": "{name}", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "password": { + "title": "Password required", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "(8 characters, see QR Code under the dustbin)." + } + }, + "zeroconf_confirm": { + "description": "Do you want to add ROMY Vacuum Cleaner {name} to Home Assistant?" + } + } + }, + "entity": { + "vacuum": { + "romy": { + "state_attributes": { + "fan_speed": { + "state": { + "default": "Default", + "normal": "Normal", + "silent": "Silent", + "intensive": "Intensive", + "super_silent": "Super silent", + "high": "High", + "auto": "Auto" + } + } + } + } + } + } +} diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py new file mode 100644 index 00000000000..0670c2a49f6 --- /dev/null +++ b/homeassistant/components/romy/vacuum.py @@ -0,0 +1,116 @@ +"""Support for Wi-Fi enabled ROMY vacuum cleaner robots. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum.romy/. +""" + + +from typing import Any + +from romy import RomyRobot + +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import RomyVacuumCoordinator + +ICON = "mdi:robot-vacuum" + +FAN_SPEED_NONE = "default" +FAN_SPEED_NORMAL = "normal" +FAN_SPEED_SILENT = "silent" +FAN_SPEED_INTENSIVE = "intensive" +FAN_SPEED_SUPER_SILENT = "super_silent" +FAN_SPEED_HIGH = "high" +FAN_SPEED_AUTO = "auto" + +FAN_SPEEDS: list[str] = [ + FAN_SPEED_NONE, + FAN_SPEED_NORMAL, + FAN_SPEED_SILENT, + FAN_SPEED_INTENSIVE, + FAN_SPEED_SUPER_SILENT, + FAN_SPEED_HIGH, + FAN_SPEED_AUTO, +] + +# Commonly supported features +SUPPORT_ROMY_ROBOT = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.FAN_SPEED +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up ROMY vacuum cleaner.""" + + coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([RomyVacuumEntity(coordinator, coordinator.romy)], True) + + +class RomyVacuumEntity(CoordinatorEntity[RomyVacuumCoordinator], StateVacuumEntity): + """Representation of a ROMY vacuum cleaner robot.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = SUPPORT_ROMY_ROBOT + _attr_fan_speed_list = FAN_SPEEDS + _attr_icon = ICON + + def __init__( + self, + coordinator: RomyVacuumCoordinator, + romy: RomyRobot, + ) -> None: + """Initialize the ROMY Robot.""" + super().__init__(coordinator) + self.romy = romy + self._attr_unique_id = self.romy.unique_id + self._device_info = DeviceInfo( + identifiers={(DOMAIN, romy.unique_id)}, + manufacturer="ROMY", + name=romy.name, + model=romy.model, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] + self._attr_battery_level = self.romy.battery_level + self._attr_state = self.romy.status + + self.async_write_ha_state() + + async def async_start(self, **kwargs: Any) -> None: + """Turn the vacuum on.""" + LOGGER.debug("async_start") + await self.romy.async_clean_start_or_continue() + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + LOGGER.debug("async_stop") + await self.romy.async_stop() + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return vacuum back to base.""" + LOGGER.debug("async_return_to_base") + await self.romy.async_return_to_base() + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + LOGGER.debug("async_set_fan_speed to %s", fan_speed) + await self.romy.async_set_fan_speed(FAN_SPEEDS.index(fan_speed)) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index 151d3bfb68e..71db96f8c21 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -2,7 +2,7 @@ from homeassistant.const import Platform DOMAIN = "roomba" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.VACUUM] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.VACUUM] CONF_CERT = "certificate" CONF_CONTINUOUS = "continuous" CONF_BLID = "blid" diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index b5dd9fedbd3..38de3a7fb2b 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -105,12 +105,12 @@ class IRobotEntity(Entity): @property def run_stats(self): """Return the run stats.""" - return self.vacuum_state.get("bbrun") + return self.vacuum_state.get("bbrun", {}) @property def mission_stats(self): """Return the mission stats.""" - return self.vacuum_state.get("bbmssn") + return self.vacuum_state.get("bbmssn", {}) @property def battery_stats(self): diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 09d4d643be9..ad2894ebb11 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,7 +11,12 @@ from homeassistant.components.sensor import ( 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 @@ -114,6 +119,18 @@ SENSORS: list[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 654c1b7fdfc..088918824d2 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -84,6 +84,9 @@ }, "scrubs_count": { "name": "Scrubs" + }, + "total_cleaned_area": { + "name": "Total cleaned area" } } } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 644dcd499a0..3db91f7926f 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/route53", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.28.17"] + "requirements": ["boto3==1.33.13"] } diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json new file mode 100644 index 00000000000..d1d544c2381 --- /dev/null +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index f20a79cc9e6..2e6f64f08e1 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -528,11 +528,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if result == RESULT_SUCCESS: new_data = dict(self._reauth_entry.data) new_data[CONF_TOKEN] = bridge.token - self.hass.config_entries.async_update_entry( - self._reauth_entry, data=new_data + return self.async_update_reload_and_abort( + self._reauth_entry, + data=new_data, ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") if result not in (RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT): return self.async_abort(reason=result) @@ -569,7 +568,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data={ **self._reauth_entry.data, @@ -577,8 +576,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_SESSION_ID: session_id, }, ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") errors = {"base": RESULT_INVALID_PIN} diff --git a/homeassistant/components/scene/icons.json b/homeassistant/components/scene/icons.json new file mode 100644 index 00000000000..3ab7264b357 --- /dev/null +++ b/homeassistant/components/scene/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:palette" + } + } +} diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index c8c0d76690d..5d747c8f345 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -81,6 +81,7 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, serial_number, api, session_id): """Initialize the thermostat.""" diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 708ecc14d16..ade210b304a 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.4"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==5.1.0"] } diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 7276ec28323..56c686df6b4 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -9,13 +9,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info from .data import ENTITY_MIGRATIONS -from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .services import async_load_screenlogic_services from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,16 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Screenlogic.""" + + async_load_screenlogic_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" @@ -56,8 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, config_entry=entry, gateway=gateway ) - async_load_screenlogic_services(hass) - await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(async_update_listener)) @@ -77,8 +86,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.gateway.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) - async_unload_screenlogic_services(hass) - return unload_ok diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1e9a90395f4..6d95f06a49c 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -81,8 +81,12 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" @@ -94,6 +98,9 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): [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,11 +147,6 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): 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: diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 8181e0f612a..104736f300b 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -20,10 +20,19 @@ DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 +ATTR_CONFIG_ENTRY = "config_entry" + SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} +SERVICE_START_SUPER_CHLORINATION = "start_super_chlorination" +ATTR_RUNTIME = "runtime" +MAX_RUNTIME = 72 +MIN_RUNTIME = 0 + +SERVICE_STOP_SUPER_CHLORINATION = "stop_super_chlorination" + LIGHT_CIRCUIT_FUNCTIONS = { FUNCTION.COLOR_WHEEL, FUNCTION.DIMMER, diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index cc5efa6c7ad..1ff611b2c9f 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,6 +1,4 @@ """Support for a ScreenLogic number entity.""" -import asyncio -from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging @@ -29,31 +27,21 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -@dataclass(frozen=True) -class ScreenLogicNumberRequiredMixin: - """Describes a required mixin for a ScreenLogic number entity.""" - - set_value_name: str - - @dataclass(frozen=True) class ScreenLogicNumberDescription( NumberEntityDescription, ScreenLogicEntityDescription, - ScreenLogicNumberRequiredMixin, ): """Describes a ScreenLogic number entity.""" SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -82,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.""" + """Base class to represent a ScreenLogic Number entity.""" entity_description: ScreenLogicNumberDescription @@ -99,13 +87,7 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): ) -> 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._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -127,6 +109,14 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): """Return the current value.""" return self.entity_data[ATTR.VALUE] + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + raise NotImplementedError() + + +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.""" @@ -134,7 +124,7 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): value = int(value) try: - await self._set_value_func(**{self._data_key: value}) + 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}" diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index c9c66183daf..116a66d97df 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -3,57 +3,132 @@ import logging from screenlogicpy import ScreenLogicError +from screenlogicpy.device_const.system import EQUIPMENT_FLAG import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + selector, +) from homeassistant.helpers.service import async_extract_config_entry_ids from .const import ( ATTR_COLOR_MODE, + ATTR_CONFIG_ENTRY, + ATTR_RUNTIME, DOMAIN, + MAX_RUNTIME, + MIN_RUNTIME, SERVICE_SET_COLOR_MODE, + SERVICE_START_SUPER_CHLORINATION, + SERVICE_STOP_SUPER_CHLORINATION, SUPPORTED_COLOR_MODES, ) +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SET_COLOR_MODE_SCHEMA = cv.make_entity_service_schema( +BASE_SERVICE_SCHEMA = vol.Schema( { - vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), - }, + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } +) + +SET_COLOR_MODE_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), + } + ), + cv.has_at_least_one_key(ATTR_CONFIG_ENTRY, *cv.ENTITY_SERVICE_FIELDS), +) + +TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( + { + vol.Optional(ATTR_RUNTIME, default=24): vol.All( + vol.Coerce(int), vol.Clamp(min=MIN_RUNTIME, max=MAX_RUNTIME) + ), + } ) @callback def async_load_screenlogic_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" - if hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - # Integration-level services have already been added. Return. - return async def extract_screenlogic_config_entry_ids(service_call: ServiceCall): - return [ - entry_id - for entry_id in await async_extract_config_entry_ids(hass, service_call) - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - ] - - async def async_set_color_mode(service_call: ServiceCall) -> None: if not ( - screenlogic_entry_ids := await extract_screenlogic_config_entry_ids( - service_call + screenlogic_entry_ids := await async_extract_config_entry_ids( + hass, service_call ) ): - raise HomeAssistantError( - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for" - " target not found" + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry for " + "target not found" ) + return screenlogic_entry_ids + + async def get_coordinators( + service_call: ServiceCall, + ) -> list[ScreenlogicDataUpdateCoordinator]: + entry_ids: set[str] + if entry_id := service_call.data.get(ATTR_CONFIG_ENTRY): + entry_ids = {entry_id} + else: + ir.async_create_issue( + hass, + DOMAIN, + "service_target_deprecation", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_target_deprecation", + ) + entry_ids = await extract_screenlogic_config_entry_ids(service_call) + + coordinators: list[ScreenlogicDataUpdateCoordinator] = [] + for entry_id in entry_ids: + config_entry: ConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if not config_entry: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not found" + ) + if not config_entry.domain == DOMAIN: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' is not a {DOMAIN} config" + ) + if not config_entry.state == ConfigEntryState.LOADED: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not loaded" + ) + coordinators.append(hass.data[DOMAIN][entry_id]) + + return coordinators + + async def async_set_color_mode(service_call: ServiceCall) -> None: color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - for entry_id in screenlogic_entry_ids: - coordinator = hass.data[DOMAIN][entry_id] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): _LOGGER.debug( "Service %s called on %s with mode %s", SERVICE_SET_COLOR_MODE, @@ -62,26 +137,60 @@ def async_load_screenlogic_services(hass: HomeAssistant): ) try: await coordinator.gateway.async_set_color_lights(color_num) - # Debounced refresh to catch any secondary - # changes in the device + # Debounced refresh to catch any secondary changes in the device await coordinator.async_request_refresh() except ScreenLogicError as error: raise HomeAssistantError(error) from error + async def async_set_super_chlor( + service_call: ServiceCall, + is_on: bool, + runtime: int | None = None, + ) -> None: + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): + if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: + raise ServiceValidationError( + f"Equipment configuration for {coordinator.gateway.name} does not" + f" support {service_call.service}" + ) + rt_log = f" with runtime {runtime}" if runtime else "" + _LOGGER.debug( + "Service %s called on %s%s", + service_call.service, + coordinator.gateway.name, + rt_log, + ) + try: + await coordinator.gateway.async_set_scg_config( + super_chlor_timer=runtime, super_chlorinate=is_on + ) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + async def async_start_super_chlor(service_call: ServiceCall) -> None: + runtime = service_call.data[ATTR_RUNTIME] + await async_set_super_chlor(service_call, True, runtime) + + async def async_stop_super_chlor(service_call: ServiceCall) -> None: + await async_set_super_chlor(service_call, False) + hass.services.async_register( DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + async_start_super_chlor, + TURN_ON_SUPER_CHLOR_SCHEMA, + ) -@callback -def async_unload_screenlogic_services(hass: HomeAssistant): - """Unload services for the ScreenLogic integration.""" - if hass.data[DOMAIN]: - # There is still another config entry for this domain, don't remove services. - return - - if not hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - return - - _LOGGER.info("Unloading ScreenLogic Services") - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_COLOR_MODE) + hass.services.async_register( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + async_stop_super_chlor, + BASE_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index 8e4a82a1079..f05537640ca 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -1,9 +1,11 @@ # ScreenLogic Services set_color_mode: - target: - device: - integration: screenlogic fields: + config_entry: + required: false + selector: + config_entry: + integration: screenlogic color_mode: required: true selector: @@ -31,3 +33,25 @@ set_color_mode: - sunset - thumper - white +start_super_chlorination: + fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic + runtime: + default: 24 + selector: + number: + min: 0 + max: 72 + unit_of_measurement: hours + mode: slider +stop_super_chlorination: + fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 4894bc6437d..755eeb4ffb2 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -41,11 +41,52 @@ "name": "Set Color Mode", "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "color_mode": { "name": "Color Mode", "description": "The ScreenLogic color mode to set." } } + }, + "start_super_chlorination": { + "name": "Start Super Chlorination", + "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "runtime": { + "name": "Run Time", + "description": "Number of hours for super chlorination to run." + } + } + }, + "stop_super_chlorination": { + "name": "Stop Super Chlorination", + "description": "Stops super chlorination.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + } + } + } + }, + "issues": { + "service_target_deprecation": { + "title": "Deprecating use of target for ScreenLogic services", + "fix_flow": { + "step": { + "confirm": { + "title": "Deprecating target for ScreenLogic services", + "description": "Use of an Area, Device, or Entity as a target for ScreenLogic services is being deprecated. Instead, use `config_entry` with the entry_id of the desired ScreenLogic integration.\n\nPlease update your automations and scripts and select **submit** to fix this issue." + } + } + } } } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 716f0197c8b..f1a86687255 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -72,6 +72,12 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_script +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCRIPT_SERVICE_SCHEMA = vol.Schema(dict) SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_VARIABLES): {str: cv.match_all}} @@ -381,7 +387,7 @@ class BaseScriptEntity(ToggleEntity, ABC): raw_config: ConfigType | None - @property + @cached_property @abstractmethod def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" @@ -391,12 +397,12 @@ class BaseScriptEntity(ToggleEntity, ABC): def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - @property + @cached_property @abstractmethod def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - @property + @cached_property @abstractmethod def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -426,7 +432,7 @@ class UnavailableScriptEntity(BaseScriptEntity): """Return the name of the entity.""" return self._name - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return set() @@ -436,12 +442,12 @@ class UnavailableScriptEntity(BaseScriptEntity): """Return referenced blueprint or None.""" return None - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return set() - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return set() @@ -509,7 +515,7 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): """Return true if script is on.""" return self.script.is_running - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return self.script.referenced_areas @@ -521,12 +527,12 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): return None return self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH] - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return self.script.referenced_devices - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return self.script.referenced_entities diff --git a/homeassistant/components/script/icons.json b/homeassistant/components/script/icons.json new file mode 100644 index 00000000000..d253d0fd829 --- /dev/null +++ b/homeassistant/components/script/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:script-text", + "state": { + "on": "mdi:script-text-play" + } + } + }, + "services": { + "reload": "mdi:reload", + "turn_on": "mdi:script-text-play", + "turn_off": "mdi:script-text", + "toggle": "mdi:script-text" + } +} diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index ac9a13850d6..7dd7d952e95 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -111,7 +111,7 @@ class Searcher: self._to_resolve: deque[tuple[str, str]] = deque() @callback - def async_search(self, item_type, item_id): + def async_search(self, item_type: str, item_id: str) -> dict[str, set[str]]: """Find results.""" _LOGGER.debug("Searching for %s/%s", item_type, item_id) self.results[item_type].add(item_id) @@ -140,7 +140,7 @@ class Searcher: return {key: val for key, val in self.results.items() if val} @callback - def _add_or_resolve(self, item_type, item_id): + def _add_or_resolve(self, item_type: str, item_id: str) -> None: """Add an item to explore.""" if item_id in self.results[item_type]: return @@ -151,7 +151,7 @@ class Searcher: self._to_resolve.append((item_type, item_id)) @callback - def _resolve_area(self, area_id) -> None: + def _resolve_area(self, area_id: str) -> None: """Resolve an area.""" for device in dr.async_entries_for_area(self._device_reg, area_id): self._add_or_resolve("device", device.id) @@ -166,7 +166,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_automation(self, automation_entity_id) -> None: + def _resolve_automation(self, automation_entity_id: str) -> None: """Resolve an automation. Will only be called if automation is an entry point. @@ -188,7 +188,7 @@ class Searcher: self._add_or_resolve("automation_blueprint", blueprint) @callback - def _resolve_automation_blueprint(self, blueprint_path) -> None: + def _resolve_automation_blueprint(self, blueprint_path: str) -> None: """Resolve an automation blueprint. Will only be called if blueprint is an entry point. @@ -199,7 +199,7 @@ class Searcher: self._add_or_resolve("automation", entity_id) @callback - def _resolve_config_entry(self, config_entry_id) -> None: + def _resolve_config_entry(self, config_entry_id: str) -> None: """Resolve a config entry. Will only be called if config entry is an entry point. @@ -215,7 +215,7 @@ class Searcher: self._add_or_resolve("entity", entity_entry.entity_id) @callback - def _resolve_device(self, device_id) -> None: + def _resolve_device(self, device_id: str) -> None: """Resolve a device.""" device_entry = self._device_reg.async_get(device_id) # Unlikely entry doesn't exist, but let's guard for bad data. @@ -239,7 +239,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_entity(self, entity_id) -> None: + def _resolve_entity(self, entity_id: str) -> None: """Resolve an entity.""" # Extra: Find automations and scripts that reference this entity. @@ -277,7 +277,7 @@ class Searcher: self._add_or_resolve(domain, entity_id) @callback - def _resolve_group(self, group_entity_id) -> None: + def _resolve_group(self, group_entity_id: str) -> None: """Resolve a group. Will only be called if group is an entry point. @@ -286,7 +286,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_person(self, person_entity_id) -> None: + def _resolve_person(self, person_entity_id: str) -> None: """Resolve a person. Will only be called if person is an entry point. @@ -295,7 +295,7 @@ class Searcher: self._add_or_resolve("entity", entity) @callback - def _resolve_scene(self, scene_entity_id) -> None: + def _resolve_scene(self, scene_entity_id: str) -> None: """Resolve a scene. Will only be called if scene is an entry point. @@ -304,7 +304,7 @@ class Searcher: self._add_or_resolve("entity", entity) @callback - def _resolve_script(self, script_entity_id) -> None: + def _resolve_script(self, script_entity_id: str) -> None: """Resolve a script. Will only be called if script is an entry point. @@ -322,7 +322,7 @@ class Searcher: self._add_or_resolve("script_blueprint", blueprint) @callback - def _resolve_script_blueprint(self, blueprint_path) -> None: + def _resolve_script_blueprint(self, blueprint_path: str) -> None: """Resolve a script blueprint. Will only be called if blueprint is an entry point. diff --git a/homeassistant/components/select/icons.json b/homeassistant/components/select/icons.json new file mode 100644 index 00000000000..1b440d2a1de --- /dev/null +++ b/homeassistant/components/select/icons.json @@ -0,0 +1,14 @@ +{ + "entity_component": { + "_": { + "default": "mdi:format-list-bulleted" + } + }, + "services": { + "select_first": "mdi:format-list-bulleted", + "select_last": "mdi:format-list-bulleted", + "select_next": "mdi:format-list-bulleted", + "select_option": "mdi:format-list-bulleted", + "select_previous": "mdi:format-list-bulleted" + } +} diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index d7f7588beb2..86b68db1e32 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -69,11 +69,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=self._auth_data[CONF_EMAIL], data=self._auth_data ) - self.hass.config_entries.async_update_entry( - existing_entry, data=self._auth_data - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(existing_entry, data=self._auth_data) async def validate_input_and_create_entry(self, user_input, errors): """Validate the input and create the entry from the data.""" diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 89e1fafa213..bcc851e02ae 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -191,6 +191,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): _attr_name = None _attr_precision = PRECISION_TENTHS _attr_translation_key = "climate_device" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -207,7 +208,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): def get_features(self) -> ClimateEntityFeature: """Get supported features.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON for key in self.device_data.full_features: if key in FIELD_TO_FLAG: features |= FIELD_TO_FLAG[key] diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index 9d998e739f0..32ad07871a3 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -14,6 +14,7 @@ TO_REDACT = { "location", "ssid", "id", + "mac", "macAddress", "parentDeviceUid", "qrId", diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6498b92b03e..05fec64608f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -219,6 +219,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _last_reset_reported = False _sensor_option_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED + _invalid_suggested_unit_of_measurement_reported = False @callback def add_to_platform_start( @@ -376,6 +377,34 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return None + def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool: + """Validate the suggested unit. + + Validate that a unit converter exists for the sensor's device class and that the + unit converter supports both the native and the suggested units of measurement. + """ + # Make sure we can convert the units + if ( + (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None + or self.native_unit_of_measurement not in unit_converter.VALID_UNITS + or suggested_unit_of_measurement not in unit_converter.VALID_UNITS + ): + if not self._invalid_suggested_unit_of_measurement_reported: + self._invalid_suggested_unit_of_measurement_reported = True + report_issue = self._suggest_report_issue() + # This should raise in Home Assistant Core 2024.5 + _LOGGER.warning( + ( + "%s sets an invalid suggested_unit_of_measurement. Please %s. " + "This warning will become an error in Home Assistant Core 2024.5" + ), + type(self), + report_issue, + ) + return False + + return True + def _get_initial_suggested_unit(self) -> str | UndefinedType: """Return the initial unit.""" # Unit suggested by the integration @@ -390,6 +419,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if suggested_unit_of_measurement is None: return UNDEFINED + # Make sure we can convert the units + if not self._is_valid_suggested_unit(suggested_unit_of_measurement): + return UNDEFINED + return suggested_unit_of_measurement def get_initial_entity_options(self) -> er.EntityOptionsType | None: @@ -416,21 +449,12 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return state attributes.""" if last_reset := self.last_reset: state_class = self.state_class - if state_class != SensorStateClass.TOTAL and not self._last_reset_reported: - self._last_reset_reported = True - report_issue = self._suggest_report_issue() - # This should raise in Home Assistant Core 2022.5 - _LOGGER.warning( - ( - "Entity %s (%s) with state_class %s has set last_reset. Setting" - " last_reset for entities with state_class other than 'total'" - " is not supported. Please update your configuration if" - " state_class is manually configured, otherwise %s" - ), - self.entity_id, - type(self), - state_class, - report_issue, + if state_class != SensorStateClass.TOTAL: + raise ValueError( + f"Entity {self.entity_id} ({type(self)}) with state_class {state_class}" + " has set last_reset. Setting last_reset for entities with state_class" + " other than 'total' is not supported. Please update your configuration" + " if state_class is manually configured." ) if state_class == SensorStateClass.TOTAL: @@ -495,16 +519,17 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self._sensor_option_unit_of_measurement is not UNDEFINED: return self._sensor_option_unit_of_measurement + native_unit_of_measurement = self.native_unit_of_measurement + # Second priority, for non registered entities: unit suggested by integration if not self.registry_entry and ( suggested_unit_of_measurement := self.suggested_unit_of_measurement ): - return suggested_unit_of_measurement + if self._is_valid_suggested_unit(suggested_unit_of_measurement): + return suggested_unit_of_measurement # Third priority: Legacy temperature conversion, which applies # to both registered and non registered entities - native_unit_of_measurement = self.native_unit_of_measurement - if ( native_unit_of_measurement in TEMPERATURE_UNITS and self.device_class is SensorDeviceClass.TEMPERATURE @@ -666,11 +691,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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. diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b1cb120e3fe..aad882821d6 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.helpers.deprecation import ( @@ -46,6 +47,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -57,6 +59,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) DOMAIN: Final = "sensor" @@ -394,6 +397,14 @@ class SensorDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + WATER = "water" """Water. @@ -475,6 +486,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, + SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, @@ -489,6 +501,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, + SensorDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -555,6 +568,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), + SensorDeviceClass.VOLUME_FLOW_RATE: set(UnitOfVolumeFlowRate), SensorDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), SensorDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -621,6 +635,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL_INCREASING, }, SensorDeviceClass.VOLUME_STORAGE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLUME_FLOW_RATE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.WATER: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index b12cdb570eb..b7cf533d3da 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -77,6 +77,7 @@ CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" +CONF_IS_VOLUME_FLOW_RATE = "is_volume_flow_rate" CONF_IS_WATER = "is_water" CONF_IS_WEIGHT = "is_weight" CONF_IS_WIND_SPEED = "is_wind_speed" @@ -132,6 +133,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_IS_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], @@ -186,6 +188,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_IS_VOLTAGE, CONF_IS_VOLUME, + CONF_IS_VOLUME_FLOW_RATE, CONF_IS_WATER, CONF_IS_WEIGHT, CONF_IS_WIND_SPEED, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 1c0da89692b..c5c19a19d0b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -76,6 +76,7 @@ CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" +CONF_VOLUME_FLOW_RATE = "volume_flow_rate" CONF_WATER = "water" CONF_WEIGHT = "weight" CONF_WIND_SPEED = "wind_speed" @@ -131,6 +132,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], @@ -186,6 +188,7 @@ TRIGGER_SCHEMA = vol.All( CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_VOLTAGE, CONF_VOLUME, + CONF_VOLUME_FLOW_RATE, CONF_WATER, CONF_WEIGHT, CONF_WIND_SPEED, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json new file mode 100644 index 00000000000..24245d9bf03 --- /dev/null +++ b/homeassistant/components/sensor/icons.json @@ -0,0 +1,154 @@ +{ + "entity_component": { + "_": { + "default": "mdi:eye" + }, + "apparent_power": { + "default": "mdi:flash" + }, + "aqi": { + "default": "mdi:air-filter" + }, + "atmospheric_pressure": { + "default": "mdi:thermometer-lines" + }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "current": { + "default": "mdi:current-ac" + }, + "data_rate": { + "default": "mdi:transmission-tower" + }, + "data_size": { + "default": "mdi:database" + }, + "date": { + "default": "mdi:calendar" + }, + "distance": { + "default": "mdi:arrow-left-right" + }, + "duration": { + "default": "mdi:progress-clock" + }, + "energy": { + "default": "mdi:lightning-bolt" + }, + "energy_storage": { + "default": "mdi:car-battery" + }, + "enum": { + "default": "mdi:eye" + }, + "frequency": { + "default": "mdi:sine-wave" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "humidity": { + "default": "mdi:water-percent" + }, + "illuminance": { + "default": "mdi:brightness-5" + }, + "irradiance": { + "default": "mdi:sun-wireless" + }, + "moisture": { + "default": "mdi:water-percent" + }, + "monetary": { + "default": "mdi:cash" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "nitrogen_monoxide": { + "default": "mdi:molecule" + }, + "nitrous_oxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "ph": { + "default": "mdi:ph" + }, + "pm1": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "power": { + "default": "mdi:flash" + }, + "power_factor": { + "default": "mdi:angle-acute" + }, + "precipitation": { + "default": "mdi:weather-rainy" + }, + "precipitation_intensity": { + "default": "mdi:weather-pouring" + }, + "pressure": { + "default": "mdi:gauge" + }, + "reactive_power": { + "default": "mdi:flash" + }, + "signal_strength": { + "default": "mdi:wifi" + }, + "sound_pressure": { + "default": "mdi:ear-hearing" + }, + "speed": { + "default": "mdi:speedometer" + }, + "sulfur_dioxide": { + "default": "mdi:molecule" + }, + "temperature": { + "default": "mdi:thermometer" + }, + "timestamp": { + "default": "mdi:clock" + }, + "volatile_organic_compounds": { + "default": "mdi:molecule" + }, + "volatile_organic_compounds_parts": { + "default": "mdi:molecule" + }, + "voltage": { + "default": "mdi:sine-wave" + }, + "volume": { + "default": "mdi:car-coolant-level" + }, + "volume_storage": { + "default": "mdi:storage-tank" + }, + "water": { + "default": "mdi:water" + }, + "weight": { + "default": "mdi:weight" + }, + "wind_speed": { + "default": "mdi:weather-windy" + } + } +} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index d08a20636ab..9a0ecbeb9a5 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -36,7 +36,13 @@ from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass +from .const import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + DOMAIN, + SensorStateClass, + UnitOfVolumeFlowRate, +) _LOGGER = logging.getLogger(__name__) @@ -52,6 +58,7 @@ EQUIVALENT_UNITS = { "RPM": REVOLUTIONS_PER_MINUTE, "ft3": UnitOfVolume.CUBIC_FEET, "m3": UnitOfVolume.CUBIC_METERS, + "ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, } # Keep track of entities for which a warning about decreasing value has been logged @@ -68,13 +75,19 @@ LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistic def _get_sensor_states(hass: HomeAssistant) -> list[State]: """Get the current state of all sensors for which to compile statistics.""" - all_sensors = hass.states.all(DOMAIN) instance = get_instance(hass) + # We check for state class first before calling the filter + # function as the filter function is much more expensive + # than checking the state class return [ state - for state in all_sensors - if instance.entity_filter(state.entity_id) - and try_parse_enum(SensorStateClass, state.attributes.get(ATTR_STATE_CLASS)) + for state in hass.states.all(DOMAIN) + if (state_class := state.attributes.get(ATTR_STATE_CLASS)) + and ( + type(state_class) is SensorStateClass + or try_parse_enum(SensorStateClass, state_class) + ) + and instance.entity_filter(state.entity_id) ] diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 1db5e4c8cfd..fad1086c034 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -45,6 +45,7 @@ "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", + "is_volume_flow_rate": "Current {entity_name} volume flow rate", "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight", "is_wind_speed": "Current {entity_name} wind speed" @@ -93,6 +94,7 @@ "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", + "volume_flow_rate": "{entity_name} volume flow rate changes", "water": "{entity_name} water changes", "weight": "{entity_name} weight changes", "wind_speed": "{entity_name} wind speed changes" @@ -260,6 +262,9 @@ "volume": { "name": "Volume" }, + "volume_flow_rate": { + "name": "Volume flow rate" + }, "volume_storage": { "name": "Stored volume" }, diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 708d9db03ee..0222a1c2884 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -5,6 +5,11 @@ { "local_name": "SensorPush*", "connectable": false + }, + { + "local_name": "s", + "connectable": false, + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9" } ], "codeowners": ["@bdraco"], @@ -12,5 +17,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.2"] } diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 2af110564e7..3c3eaeb78e3 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.37.1"] + "requirements": ["sentry-sdk==1.39.2"] } diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index a94941ac642..c921e1ac1da 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -45,6 +45,7 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): _attr_min_temp = 5 _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 80b428b908e..6c511e3f44e 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 4cfbb033566..e1330b06c08 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL -class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): +class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): # pylint: disable=hass-enforce-coordinator-module """Define a wrapper class to update Shark IQ data.""" def __init__( diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6b8d100ea8f..142b5f9c521 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -9,6 +9,7 @@ from aioshelly.common import ConnectionOptions from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -37,6 +38,7 @@ from .const import ( DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, @@ -50,6 +52,7 @@ from .coordinator import ( get_entry_data, ) from .utils import ( + async_create_issue_unsupported_firmware, get_block_device_sleep_period, get_coap_context, get_device_entry_gen, @@ -216,6 +219,9 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err await _async_block_device_setup() elif sleep_period is None or device_entry is None: @@ -230,6 +236,9 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b LOGGER.debug("Setting up offline block device %s", entry.title) await _async_block_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True @@ -296,6 +305,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo LOGGER.debug("Setting up online RPC device %s", entry.title) try: await device.initialize() + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: @@ -314,6 +326,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo LOGGER.debug("Setting up offline block device %s", entry.title) await _async_rpc_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 4ad51e5cc0f..e9c8e909e87 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -55,7 +55,7 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr """Class to describe a REST binary sensor.""" -SENSORS: Final = { +SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { ("device", "overtemp"): BlockBinarySensorDescription( key="device|overtemp", name="Overheating", diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 2f9019ba5e6..5432ceb3a12 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from aioshelly.ble import async_start_scanner +from aioshelly.ble import async_start_scanner, create_scanner from aioshelly.ble.const import ( BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION, @@ -12,15 +12,11 @@ from aioshelly.ble.const import ( DEFAULT_WINDOW_MS, ) -from homeassistant.components.bluetooth import ( - HaBluetoothConnector, - async_register_scanner, -) +from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.helpers.device_registry import format_mac from ..const import BLEScannerMode -from .scanner import ShellyBLEScanner if TYPE_CHECKING: from ..coordinator import ShellyRpcCoordinator @@ -35,13 +31,7 @@ async def async_connect_scanner( device = coordinator.device entry = coordinator.entry source = format_mac(coordinator.mac).upper() - connector = HaBluetoothConnector( - # no active connections to shelly yet - client=None, # type: ignore[arg-type] - source=source, - can_connect=lambda: False, - ) - scanner = ShellyBLEScanner(source, entry.title, connector, False) + scanner = create_scanner(source, entry.title) unload_callbacks = [ async_register_scanner(hass, scanner), scanner.async_setup(), diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py deleted file mode 100644 index 7c0dc3c792a..00000000000 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Bluetooth scanner for shelly.""" -from __future__ import annotations - -from typing import Any - -from aioshelly.ble import parse_ble_scan_result_event -from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION - -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner -from homeassistant.core import callback - -from ..const import LOGGER - - -class ShellyBLEScanner(BaseHaRemoteScanner): - """Scanner for shelly.""" - - @callback - def async_on_event(self, event: dict[str, Any]) -> None: - """Process an event from the shelly and ignore if its not a ble.scan_result.""" - if event.get("event") != BLE_SCAN_RESULT_EVENT: - return - - data = event["data"] - - if data[0] != BLE_SCAN_RESULT_VERSION: - LOGGER.warning("Unsupported BLE scan result version: %s", data[0]) - return - - try: - address, rssi, parsed = parse_ble_scan_result_event(data) - except Exception as err: # pylint: disable=broad-except - # Broad exception catch because we have no - # control over the data that is coming in. - LOGGER.error("Failed to parse BLE event: %s", err, exc_info=True) - return - - self._async_on_advertisement( - address, - rssi, - parsed.local_name, - parsed.service_uuids, - parsed.service_data, - parsed.manufacturer_data, - parsed.tx_power, - {}, - MONOTONIC_TIME(), - ) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 64129131d0a..59343ca6d2f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -160,10 +160,14 @@ class BlockSleepingClimate( _attr_max_temp = SHTRV_01_TEMPERATURE_SETTINGS["max"] _attr_min_temp = SHTRV_01_TEMPERATURE_SETTINGS["min"] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -438,9 +442,14 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): _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_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize.""" diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 59ae6eed196..2ae5a74bb42 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -201,12 +201,18 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if get_info_gen(self.info) in RPC_GENERATIONS: schema = { - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } else: schema = { - vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME)): str, - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } return self.async_show_form( @@ -330,11 +336,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.entry, data={**self.entry.data, **user_input} ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") if get_device_entry_gen(self.entry) in BLOCK_GENERATIONS: schema = { diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 6cc513015d3..827a6c00a30 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -212,6 +212,8 @@ PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" +FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 7f88cce1134..86fd98b527e 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -7,10 +7,9 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any, Generic, TypeVar, cast -import aioshelly from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType -from aioshelly.const import MODEL_VALVE +from aioshelly.const import MODEL_NAMES, MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType @@ -137,7 +136,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, manufacturer="Shelly", - model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + model=MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.entry)} ({self.model})", configuration_url=f"http://{self.entry.data[CONF_HOST]}", diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 4390790c794..caff64d7707 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -71,7 +71,7 @@ class BlockShellyCover(ShellyBlockEntity, CoverEntity): """Entity that controls a cover on block based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_supported_features = ( + _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) @@ -147,7 +147,7 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): """Entity that controls a cover on RPC based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_supported_features = ( + _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 3132f1f571e..3dd156e9e30 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -172,6 +172,7 @@ def async_setup_rpc_attribute_entities( coordinator = get_entry_data(hass)[config_entry.entry_id].rpc assert coordinator + polling_coordinator = None if not (sleep_period := config_entry.data[CONF_SLEEP_PERIOD]): polling_coordinator = get_entry_data(hass)[config_entry.entry_id].rpc_poll assert polling_coordinator diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 7e49dc78e4d..234f376e85f 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -221,7 +221,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): red = self.block.red green = self.block.green blue = self.block.blue - return (red, green, blue) + return (cast(int, red), cast(int, green), cast(int, blue)) @property def rgbw_color(self) -> tuple[int, int, int, int]: @@ -231,7 +231,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): else: white = self.block.white - return (*self.rgb_color, white) + return (*self.rgb_color, cast(int, white)) @property def color_temp_kelvin(self) -> int: @@ -262,9 +262,9 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): effect_index = self.block.effect if self.coordinator.model == MODEL_BULB: - return SHBLB_1_RGB_EFFECTS[effect_index] + return SHBLB_1_RGB_EFFECTS[cast(int, effect_index)] - return STANDARD_RGB_EFFECTS[effect_index] + return STANDARD_RGB_EFFECTS[cast(int, effect_index)] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 82833bf34af..e08b04d16a3 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==7.1.0"], + "requirements": ["aioshelly==8.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5d35e71ce5d..4cab817e67c 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final, cast +from typing import Any, cast from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError @@ -37,7 +37,7 @@ class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): rest_arg: str = "" -NUMBERS: Final = { +NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", icon="mdi:pipe-valve", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b439a19e318..e46800963a3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -69,7 +69,7 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" -SENSORS: Final = { +SENSORS: dict[tuple[str, str], BlockSensorDescription] = { ("device", "battery"): BlockSensorDescription( key="device|battery", name="Battery", @@ -907,6 +907,9 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + removal_condition=lambda config, _status, key: ( + config[key]["sta"]["enable"] is False + ), entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), @@ -955,7 +958,6 @@ RPC_SENSORS: Final = { sub_key="percent", name="Analog input", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c1f9b799444..9676c24f883 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -168,6 +168,10 @@ "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." + }, + "unsupported_firmware": { + "title": "Unsupported firmware for device {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d40b22ca50a..f5196504fe6 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -22,7 +22,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import singleton +from homeassistant.helpers import issue_registry as ir, singleton from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, async_get as dr_async_get, @@ -38,6 +38,7 @@ from .const import ( DEFAULT_COAP_PORT, DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, @@ -426,3 +427,23 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL + + +@callback +def async_create_issue_unsupported_firmware( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Create a repair issue if the device runs an unsupported firmware.""" + ir.async_create_issue( + hass, + DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_firmware", + translation_placeholders={ + "device_name": entry.title, + "ip_address": entry.data["host"], + }, + ) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index e2f04b5d880..e030f15d26e 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,10 +1,13 @@ """Support to manage a shopping list.""" +from __future__ import annotations + from collections.abc import Callable from http import HTTPStatus import logging from typing import Any, cast import uuid +from aiohttp import web import voluptuous as vol from homeassistant import config_entries @@ -12,7 +15,7 @@ from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -197,9 +200,15 @@ class ShoppingData: self.items: list[dict[str, JsonValueType]] = [] self._listeners: list[Callable[[], None]] = [] - async def async_add(self, name, complete=False, context=None): + async def async_add( + self, name: str | None, complete: bool = False, context: Context | None = None + ) -> dict[str, JsonValueType]: """Add a shopping list item.""" - item = {"name": name, "id": uuid.uuid4().hex, "complete": complete} + item: dict[str, JsonValueType] = { + "name": name, + "id": uuid.uuid4().hex, + "complete": complete, + } self.items.append(item) await self.hass.async_add_executor_job(self.save) self._async_notify() @@ -211,7 +220,7 @@ class ShoppingData: return item async def async_remove( - self, item_id: str, context=None + self, item_id: str, context: Context | None = None ) -> dict[str, JsonValueType] | None: """Remove a shopping list item.""" removed = await self.async_remove_items( @@ -220,7 +229,7 @@ class ShoppingData: return next(iter(removed), None) async def async_remove_items( - self, item_ids: set[str], context=None + self, item_ids: set[str], context: Context | None = None ) -> list[dict[str, JsonValueType]]: """Remove a shopping list item.""" items_dict: dict[str, dict[str, JsonValueType]] = {} @@ -248,7 +257,9 @@ class ShoppingData: ) return removed - async def async_update(self, item_id, info, context=None): + async def async_update( + self, item_id: str | None, info: dict[str, Any], context: Context | None = None + ) -> dict[str, JsonValueType]: """Update a shopping list item.""" item = next((itm for itm in self.items if itm["id"] == item_id), None) @@ -266,7 +277,7 @@ class ShoppingData: ) return item - async def async_clear_completed(self, context=None): + async def async_clear_completed(self, context: Context | None = None) -> None: """Clear completed items.""" self.items = [itm for itm in self.items if not itm["complete"]] await self.hass.async_add_executor_job(self.save) @@ -277,7 +288,9 @@ class ShoppingData: context=context, ) - async def async_update_list(self, info, context=None): + async def async_update_list( + self, info: dict[str, JsonValueType], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: """Update all items in the list.""" for item in self.items: item.update(info) @@ -291,7 +304,9 @@ class ShoppingData: return self.items @callback - def async_reorder(self, item_ids, context=None): + def async_reorder( + self, item_ids: list[str], context: Context | None = None + ) -> None: """Reorder items.""" # The array for sorted items. new_items = [] @@ -346,9 +361,11 @@ class ShoppingData: {"action": "reorder"}, ) - async def async_sort(self, reverse=False, context=None): + async def async_sort( + self, reverse: bool = False, context: Context | None = None + ) -> None: """Sort items by name.""" - self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( @@ -376,7 +393,7 @@ class ShoppingData: def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: """Add a listener to notify when data is updated.""" - def unsub(): + def unsub() -> None: self._listeners.remove(cb) self._listeners.append(cb) @@ -395,7 +412,7 @@ class ShoppingListView(http.HomeAssistantView): name = "api:shopping_list" @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Retrieve shopping list items.""" return self.json(request.app["hass"].data[DOMAIN].items) @@ -406,12 +423,13 @@ class UpdateShoppingListItemView(http.HomeAssistantView): url = "/api/shopping_list/item/{item_id}" name = "api:shopping_list:item:id" - async def post(self, request, item_id): + async def post(self, request: web.Request, item_id: str) -> web.Response: """Update a shopping list item.""" data = await request.json() + hass: HomeAssistant = request.app["hass"] try: - item = await request.app["hass"].data[DOMAIN].async_update(item_id, data) + item = await hass.data[DOMAIN].async_update(item_id, data) return self.json(item) except NoMatchingShoppingListItem: return self.json_message("Item not found", HTTPStatus.NOT_FOUND) @@ -426,9 +444,10 @@ class CreateShoppingListItemView(http.HomeAssistantView): name = "api:shopping_list:item" @RequestDataValidator(vol.Schema({vol.Required("name"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Create a new shopping list item.""" - item = await request.app["hass"].data[DOMAIN].async_add(data["name"]) + hass: HomeAssistant = request.app["hass"] + item = await hass.data[DOMAIN].async_add(data["name"]) return self.json(item) @@ -438,9 +457,9 @@ class ClearCompletedItemsView(http.HomeAssistantView): url = "/api/shopping_list/clear_completed" name = "api:shopping_list:clear_completed" - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] await hass.data[DOMAIN].async_clear_completed() return self.json_message("Cleared completed items.") diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index d6a29eb73f3..180007c2dfb 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,6 +1,7 @@ """Intents for the Shopping List integration.""" from __future__ import annotations +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -10,7 +11,7 @@ INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_LAST_ITEMS = "HassShoppingListLastItems" -async def async_setup_intents(hass): +async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the Shopping List intents.""" intent.async_register(hass, AddItemIntent()) intent.async_register(hass, ListTopItemsIntent()) @@ -22,7 +23,7 @@ class AddItemIntent(intent.IntentHandler): intent_type = INTENT_ADD_ITEM slot_schema = {"item": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) item = slots["item"]["value"] @@ -39,7 +40,7 @@ class ListTopItemsIntent(intent.IntentHandler): intent_type = INTENT_LAST_ITEMS slot_schema = {"item": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" items = intent_obj.hass.data[DOMAIN].items[-5:] response = intent_obj.create_response() diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 208e2d31de4..e63864af707 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.1.0", "simplehound==0.3"] + "requirements": ["Pillow==10.2.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 772b6f9cbf6..1e558356ea3 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -456,7 +456,7 @@ class SimpliSafe: @callback def _async_process_new_notifications(self, system: SystemType) -> None: """Act on any new system notifications.""" - if self._hass.state != CoreState.running: + if self._hass.state is not CoreState.running: # If HASS isn't fully running yet, it may cause the SIMPLISAFE_NOTIFICATION # event to fire before dependent components (like automation) are fully # ready. If that's the case, skip: diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index b895be83f2e..71f250b0e02 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -13,7 +13,7 @@ from simplipy.websocket import ( EVENT_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, EVENT_AWAY_EXIT_DELAY_BY_REMOTE, - EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_KEYPAD, EVENT_DISARMED_BY_REMOTE, EVENT_ENTRY_DELAY, EVENT_HOME_EXIT_DELAY, @@ -86,7 +86,7 @@ STATE_MAP_FROM_WEBSOCKET_EVENT = { EVENT_ARMED_HOME: STATE_ALARM_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD: STATE_ALARM_ARMING, EVENT_AWAY_EXIT_DELAY_BY_REMOTE: STATE_ALARM_ARMING, - EVENT_DISARMED_BY_MASTER_PIN: STATE_ALARM_DISARMED, + EVENT_DISARMED_BY_KEYPAD: STATE_ALARM_DISARMED, EVENT_DISARMED_BY_REMOTE: STATE_ALARM_DISARMED, EVENT_ENTRY_DELAY: STATE_ALARM_PENDING, EVENT_HOME_EXIT_DELAY: STATE_ALARM_ARMING, @@ -103,7 +103,7 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( EVENT_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, EVENT_AWAY_EXIT_DELAY_BY_REMOTE, - EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_KEYPAD, EVENT_DISARMED_BY_REMOTE, EVENT_HOME_EXIT_DELAY, ) diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index a11ddc04d64..220ca89d170 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -19,20 +19,13 @@ from .const import DOMAIN from .typing import SystemType -@dataclass(frozen=True) -class SimpliSafeButtonDescriptionMixin: - """Define an entity description mixin for SimpliSafe buttons.""" +@dataclass(frozen=True, kw_only=True) +class SimpliSafeButtonDescription(ButtonEntityDescription): + """Describe a SimpliSafe button entity.""" push_action: Callable[[System], Awaitable] -@dataclass(frozen=True) -class SimpliSafeButtonDescription( - ButtonEntityDescription, SimpliSafeButtonDescriptionMixin -): - """Describe a SimpliSafe button entity.""" - - BUTTON_KIND_CLEAR_NOTIFICATIONS = "clear_notifications" diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index d0d2a4c5689..17afd74cd06 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2023.08.0"] + "requirements": ["simplisafe-python==2024.01.0"] } diff --git a/homeassistant/components/siren/icons.json b/homeassistant/components/siren/icons.json new file mode 100644 index 00000000000..0083a2540c7 --- /dev/null +++ b/homeassistant/components/siren/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:bullhorn" + } + }, + "services": { + "toggle": "mdi:bullhorn", + "turn_off": "mdi:bullhorn", + "turn_on": "mdi:bullhorn" + } +} diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 62bd3930c77..db29e5ab586 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.4.1"] + "requirements": ["asyncsleepiq==1.5.2"] } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f07c293939a..4c2afa45b7f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -162,6 +162,8 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" + _enable_turn_on_off_backwards_compatibility = False + def __init__(self, device): """Init the class.""" super().__init__(device) @@ -173,6 +175,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._device.get_capability( Capability.thermostat_fan_mode, Capability.thermostat @@ -341,6 +345,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _hvac_modes: list[HVACMode] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device) -> None: """Init the class.""" @@ -353,7 +358,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_supported_features(self) -> ClimateEntityFeature: features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._device.get_capability(Capability.fan_oscillation_mode): features |= ClimateEntityFeature.SWING_MODE diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 1bd21cd73cd..393242a30dd 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -34,15 +34,15 @@ STORAGE_VERSION = 1 # Ordered 'specific to least-specific platform' in order for capabilities # to be drawn-down and represented by the most appropriate platform. PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.LOCK, - Platform.COVER, - Platform.SWITCH, - Platform.BINARY_SENSOR, - Platform.SENSOR, Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, ] IGNORED_CAPABILITIES = [ diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 6c814b781b2..0e15ea7800a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -41,19 +41,52 @@ async def async_setup_entry( def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: """Return all capabilities supported if minimum required are present.""" - supported = [Capability.switch, Capability.fan_speed] - # Must have switch and fan_speed - if all(capability in capabilities for capability in supported): - return supported - return None + + # MUST support switch as we need a way to turn it on and off + if Capability.switch not in capabilities: + return None + + # These are all optional but at least one must be supported + optional = [ + Capability.air_conditioner_fan_mode, + Capability.fan_speed, + ] + + # At least one of the optional capabilities must be supported + # to classify this entity as a fan. + # If they are not then return None and don't setup the platform. + if not any(capability in capabilities for capability in optional): + return None + + supported = [Capability.switch] + + for capability in optional: + if capability in capabilities: + supported.append(capability) + + return supported class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_speed_count = int_states_in_range(SPEED_RANGE) + def __init__(self, device): + """Init the class.""" + super().__init__(device) + self._attr_supported_features = self._determine_features() + + def _determine_features(self): + flags = FanEntityFeature(0) + + if self._device.get_capability(Capability.fan_speed): + flags |= FanEntityFeature.SET_SPEED + if self._device.get_capability(Capability.air_conditioner_fan_mode): + flags |= FanEntityFeature.PRESET_MODE + + return flags + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" await self._async_set_percentage(percentage) @@ -70,6 +103,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset_mode of the fan.""" + await self._device.set_fan_mode(preset_mode, set_status=True) + self.async_write_ha_state() + async def async_turn_on( self, percentage: int | None = None, @@ -77,7 +115,15 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - await self._async_set_percentage(percentage) + if FanEntityFeature.SET_SPEED in self._attr_supported_features: + # If speed is set in features then turn the fan on with the speed. + await self._async_set_percentage(percentage) + else: + # If speed is not valid then turn on the fan with the + await self._device.switch_on(set_status=True) + # 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() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" @@ -92,6 +138,22 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): return self._device.status.switch @property - def percentage(self) -> int: + def percentage(self) -> int | None: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., auto, smart, interval, favorite. + + Requires FanEntityFeature.PRESET_MODE. + """ + return self._device.status.fan_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires FanEntityFeature.PRESET_MODE. + """ + return self._device.status.supported_ac_fan_modes diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 9f1802e7327..4921fca022d 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -67,6 +67,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = list(PRESET_MODES.values()) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, spa): """Initialize the entity.""" diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index eb05edcbefa..d2188eeec73 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -134,18 +134,20 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): self._pairing_task = self.hass.async_create_task( self._async_wait_for_pairing_mode() ) + + if not self._pairing_task.done(): return self.async_show_progress( step_id="wait_for_pairing_mode", progress_action="wait_for_pairing_mode", + progress_task=self._pairing_task, ) try: await self._pairing_task except asyncio.TimeoutError: - self._pairing_task = None return self.async_show_progress_done(next_step_id="pairing_timeout") - - self._pairing_task = None + finally: + self._pairing_task = None return self.async_show_progress_done(next_step_id="pairing_complete") @@ -192,15 +194,10 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): ) -> bool: return device.supported(service_info) and device.is_pairing - try: - await async_process_advertisements( - self.hass, - is_device_in_pairing_mode, - {"address": self._discovery.info.address}, - BluetoothScanningMode.ACTIVE, - WAIT_FOR_PAIRING_TIMEOUT, - ) - finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + await async_process_advertisements( + self.hass, + is_device_in_pairing_mode, + {"address": self._discovery.info.address}, + BluetoothScanningMode.ACTIVE, + WAIT_FOR_PAIRING_TIMEOUT, + ) diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index aa948703118..bbcc29d7853 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -1,5 +1,9 @@ """Support for Soma Smartshades.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging +from typing import Any, TypeVar from api.soma_api import SomaApi from requests import RequestException @@ -17,6 +21,8 @@ from homeassistant.helpers.typing import ConfigType from .const import API, DOMAIN, HOST, PORT from .utils import is_api_response_success +_SomaEntityT = TypeVar("_SomaEntityT", bound="SomaEntity") + _LOGGER = logging.getLogger(__name__) DEVICES = "devices" @@ -69,10 +75,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def soma_api_call(api_call): +def soma_api_call( + api_call: Callable[[_SomaEntityT], Coroutine[Any, Any, dict]], +) -> Callable[[_SomaEntityT], Coroutine[Any, Any, dict]]: """Soma api call decorator.""" - async def inner(self) -> dict: + async def inner(self: _SomaEntityT) -> dict: response = {} try: response_from_api = await api_call(self) diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index ce78b8c9f03..d4d33a77d43 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.16"], + "requirements": ["python-songpal==0.16.1"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 90cadcdad37..05b69c54c50 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -76,6 +76,10 @@ class SonosEntity(Entity): @property def device_info(self) -> DeviceInfo: """Return information about the device.""" + suggested_area: str | None = None + if not self.speaker.battery_info: + # Only set suggested area for non-portable devices + suggested_area = self.speaker.zone_name return DeviceInfo( identifiers={(DOMAIN, self.soco.uid)}, name=self.speaker.zone_name, @@ -86,7 +90,7 @@ class SonosEntity(Entity): (dr.CONNECTION_UPNP, f"uuid:{self.speaker.uid}"), }, manufacturer="Sonos", - suggested_area=self.speaker.zone_name, + suggested_area=suggested_area, configuration_url=f"http://{self.soco.ip_address}:1400/support/review", ) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 0e1a1d7daa4..58a0ec3b7ee 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.30.0", "sonos-websocket==0.1.2"], + "requirements": ["soco==0.30.2", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 67abecdf5d0..1fb368b13c7 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Request a refresh.""" await coordinator.async_request_refresh() - if hass.state == CoreState.running: + if hass.state is CoreState.running: await coordinator.async_config_entry_first_refresh() else: # Running a speed test during startup can prevent diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 1498c4b0039..15ba19e9b3a 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -43,6 +43,7 @@ class SpiderThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, thermostat): """Initialize the thermostat.""" @@ -53,6 +54,13 @@ class SpiderThermostat(ClimateEntity): for operation_value in thermostat.operation_values: if operation_value in SPIDER_STATE_TO_HA: self.support_hvac.append(SPIDER_STATE_TO_HA[operation_value]) + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if thermostat.has_fan_mode: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE @property def device_info(self) -> DeviceInfo: @@ -65,15 +73,6 @@ class SpiderThermostat(ClimateEntity): name=self.thermostat.name, ) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.thermostat.has_fan_mode: - return ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - return ClimateEntityFeature.TARGET_TEMPERATURE - @property def unique_id(self): """Return the id of the thermostat, if any.""" diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py index 503625fedd2..e48e963637a 100644 --- a/homeassistant/components/spider/const.py +++ b/homeassistant/components/spider/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "spider" DEFAULT_SCAN_INTERVAL = 300 -PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 0204cc30fbb..03b703220c3 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import requests from spotipy import SpotifyException @@ -33,6 +34,10 @@ from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES from .util import fetch_image_url +_SpotifyMediaPlayerT = TypeVar("_SpotifyMediaPlayerT", bound="SpotifyMediaPlayer") +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) @@ -80,14 +85,18 @@ async def async_setup_entry( async_add_entities([spotify], True) -def spotify_exception_handler(func): +def spotify_exception_handler( + func: Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R], +) -> Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R | None]: """Decorate Spotify calls to handle Spotify exception. A decorator that wraps the passed in function, catches Spotify errors, aiohttp exceptions and handles the availability of the media player. """ - def wrapper(self, *args, **kwargs): + def wrapper( + self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: # pylint: disable=protected-access try: result = func(self, *args, **kwargs) @@ -95,6 +104,7 @@ def spotify_exception_handler(func): return result except requests.RequestException: self._attr_available = False + return None except SpotifyException as exc: self._attr_available = False if exc.reason == "NO_ACTIVE_DEVICE": diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 5ebd79b09a5..1188a9ec05e 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", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.25", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 603cceec222..1a43601940e 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -70,6 +70,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DISTANCE, icon="mdi:counter", ), + SensorEntityDescription( + key="gps_count", + translation_key="gps_count", + icon="mdi:satellite-variant", + native_unit_of_measurement="satellites", + ), ) @@ -132,6 +138,8 @@ class StarlineSensor(StarlineEntity, SensorEntity): 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 9631dbf7479..6f0c42f0882 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -99,6 +99,9 @@ }, "mileage": { "name": "Mileage" + }, + "gps_count": { + "name": "GPS satellites" } }, "switch": { diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 88cce6c52d7..cedd1b3dd90 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -73,9 +73,13 @@ class StiebelEltron(ClimateEntity): _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, ste_data): """Initialize the unit.""" diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index d0ca500ded4..efc0eb24dd7 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -5,13 +5,10 @@ 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.update_coordinator import CoordinatorEntity from . import StreamlabsCoordinator from .const import DOMAIN -from .coordinator import StreamlabsData - -NAME_AWAY_MODE = "Water Away Mode" +from .entity import StreamlabsWaterEntity async def async_setup_entry( @@ -22,31 +19,19 @@ async def async_setup_entry( """Set up Streamlabs water binary sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - entities = [] - - for location_id in coordinator.data: - entities.append(StreamlabsAwayMode(coordinator, location_id)) - - async_add_entities(entities) + async_add_entities( + StreamlabsAwayMode(coordinator, location_id) for location_id in coordinator.data + ) -class StreamlabsAwayMode(CoordinatorEntity[StreamlabsCoordinator], BinarySensorEntity): +class StreamlabsAwayMode(StreamlabsWaterEntity, BinarySensorEntity): """Monitor the away mode state.""" + _attr_translation_key = "away_mode" + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the away mode device.""" - super().__init__(coordinator) - self._location_id = location_id - - @property - def location_data(self) -> StreamlabsData: - """Returns the data object.""" - return self.coordinator.data[self._location_id] - - @property - def name(self) -> str: - """Return the name for away mode.""" - return f"{self.location_data.name} {NAME_AWAY_MODE}" + super().__init__(coordinator, location_id, "away_mode") @property def is_on(self) -> bool: diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index dc57ae78810..bcb2e7790d4 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -50,8 +50,8 @@ class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): res[location_id] = StreamlabsData( is_away=location["homeAway"] == "away", name=location["name"], - daily_usage=round(water_usage["today"], 1), - monthly_usage=round(water_usage["thisMonth"], 1), - yearly_usage=round(water_usage["thisYear"], 1), + 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 00000000000..4458523a07f --- /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/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 6c869a6d1bc..d9bb76814b5 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,20 +1,59 @@ """Support for Streamlabs Water Monitor Usage.""" from __future__ import annotations -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.update_coordinator import CoordinatorEntity +from homeassistant.helpers.typing import StateType from . import StreamlabsCoordinator from .const import DOMAIN from .coordinator import StreamlabsData +from .entity import StreamlabsWaterEntity -NAME_DAILY_USAGE = "Daily Water" -NAME_MONTHLY_USAGE = "Monthly Water" -NAME_YEARLY_USAGE = "Yearly Water" + +@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( @@ -25,70 +64,29 @@ async def async_setup_entry( """Set up Streamlabs water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - entities = [] - - for location_id in coordinator.data: - entities.extend( - [ - StreamLabsDailyUsage(coordinator, location_id), - StreamLabsMonthlyUsage(coordinator, location_id), - StreamLabsYearlyUsage(coordinator, location_id), - ] - ) - - async_add_entities(entities) + async_add_entities( + StreamLabsSensor(coordinator, location_id, entity_description) + for location_id in coordinator.data + for entity_description in SENSORS + ) -class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], 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, coordinator: StreamlabsCoordinator, location_id: str) -> None: + def __init__( + self, + coordinator: StreamlabsCoordinator, + location_id: str, + entity_description: StreamlabsWaterSensorEntityDescription, + ) -> None: """Initialize the daily water usage device.""" - super().__init__(coordinator) - self._location_id = location_id + super().__init__(coordinator, location_id, entity_description.key) + self.entity_description = entity_description @property - def location_data(self) -> StreamlabsData: - """Returns the data object.""" - return self.coordinator.data[self._location_id] - - @property - def name(self) -> str: - """Return the name for daily usage.""" - return f"{self.location_data.name} {NAME_DAILY_USAGE}" - - @property - def native_value(self) -> float: + def native_value(self) -> StateType: """Return the current daily usage.""" - return self.location_data.daily_usage - - -class StreamLabsMonthlyUsage(StreamLabsDailyUsage): - """Monitors the monthly water usage.""" - - @property - def name(self) -> str: - """Return the name for monthly usage.""" - return f"{self.location_data.name} {NAME_MONTHLY_USAGE}" - - @property - def native_value(self) -> float: - """Return the current monthly usage.""" - return self.location_data.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_data.name} {NAME_YEARLY_USAGE}" - - @property - def native_value(self) -> float: - """Return the current yearly usage.""" - return self.location_data.yearly_usage + return self.entity_description.value_fn(self.location_data) diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index e6b5dd7465b..204f7e831ef 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -31,6 +31,24 @@ } } }, + "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", diff --git a/homeassistant/components/stt/icons.json b/homeassistant/components/stt/icons.json new file mode 100644 index 00000000000..23aa9a611be --- /dev/null +++ b/homeassistant/components/stt/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:microphone-message" + } + } +} diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 45f8ccefc68..bd1cfbca3d2 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform from .const import ( @@ -37,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 @@ -51,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 = {} @@ -73,7 +78,9 @@ def async_setup_legacy( 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) @@ -82,6 +89,7 @@ def async_setup_legacy( 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/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 4602df27748..6df2e3870d7 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume 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.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 @@ -89,21 +90,27 @@ async def async_setup_entry( ) -> None: """Set up Suez Water sensor from a config entry.""" client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezSensor(client)], True) + async_add_entities([SuezSensor(client, entry.data[CONF_COUNTER_ID])], True) class SuezSensor(SensorEntity): """Representation of a Sensor.""" - _attr_name = "Suez Water Client" - _attr_icon = "mdi:water-pump" + _attr_has_entity_name = True + _attr_translation_key = "water_usage_yesterday" _attr_native_unit_of_measurement = UnitOfVolume.LITERS _attr_device_class = SensorDeviceClass.WATER - def __init__(self, client: SuezClient) -> None: + def __init__(self, client: SuezClient, counter_id: int) -> None: """Initialize the data object.""" self.client = client self._attr_extra_state_attributes = {} + self._attr_unique_id = f"{counter_id}_water_usage_yesterday" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(counter_id))}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Suez", + ) def _fetch_data(self) -> None: """Fetch latest data from Suez.""" diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index 09df3ead17f..b4b81a788b5 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -18,6 +18,13 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "sensor": { + "water_usage_yesterday": { + "name": "Water usage yesterday" + } + } + }, "issues": { "deprecated_yaml_import_issue_invalid_auth": { "title": "The Suez water YAML configuration import failed", diff --git a/homeassistant/components/sun/icons.json b/homeassistant/components/sun/icons.json new file mode 100644 index 00000000000..9d903fd7b8e --- /dev/null +++ b/homeassistant/components/sun/icons.json @@ -0,0 +1,33 @@ +{ + "entity": { + "sensor": { + "next_dawn": { + "default": "mdi:sun-clock" + }, + "next_dusk": { + "default": "mdi:sun-clock" + }, + "next_midnight": { + "default": "mdi:sun-clock" + }, + "next_noon": { + "default": "mdi:sun-clock" + }, + "next_rising": { + "default": "mdi:sun-clock" + }, + "next_setting": { + "default": "mdi:sun-clock" + }, + "solar_elevation": { + "default": "mdi:theme-light-dark" + }, + "solar_azimuth": { + "default": "mdi:sun-angle" + }, + "solar_rising": { + "default": "mdi:sun-clock" + } + } + } +} diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 384e356fdd6..2a21b9d0246 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -39,7 +39,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_dawn", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_dawn", - icon="mdi:sun-clock", value_fn=lambda data: data.next_dawn, signal=SIGNAL_EVENTS_CHANGED, ), @@ -47,7 +46,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_dusk", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_dusk", - icon="mdi:sun-clock", value_fn=lambda data: data.next_dusk, signal=SIGNAL_EVENTS_CHANGED, ), @@ -55,7 +53,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_midnight", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_midnight", - icon="mdi:sun-clock", value_fn=lambda data: data.next_midnight, signal=SIGNAL_EVENTS_CHANGED, ), @@ -63,7 +60,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_noon", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_noon", - icon="mdi:sun-clock", value_fn=lambda data: data.next_noon, signal=SIGNAL_EVENTS_CHANGED, ), @@ -71,7 +67,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_rising", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_rising", - icon="mdi:sun-clock", value_fn=lambda data: data.next_rising, signal=SIGNAL_EVENTS_CHANGED, ), @@ -79,14 +74,12 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_setting", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_setting", - icon="mdi:sun-clock", value_fn=lambda data: data.next_setting, signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="solar_elevation", translation_key="solar_elevation", - icon="mdi:theme-light-dark", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_elevation, entity_registry_enabled_default=False, @@ -96,7 +89,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="solar_azimuth", translation_key="solar_azimuth", - icon="mdi:sun-angle", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_azimuth, entity_registry_enabled_default=False, @@ -106,7 +98,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="solar_rising", translation_key="solar_rising", - icon="mdi:sun-clock", value_fn=lambda data: data.rising, entity_registry_enabled_default=False, signal=SIGNAL_EVENTS_CHANGED, diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index de0b3406f05..b681ecc6d5f 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunweg/", "iot_class": "cloud_polling", "loggers": ["sunweg"], - "requirements": ["sunweg==2.0.3"] + "requirements": ["sunweg==2.1.0"] } diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 9189ea38c00..d4c337c4096 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,25 +9,12 @@ from surepy.enums import EntityType, Location, LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - Platform, -) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, -) +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant, ServiceCall 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.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -35,9 +22,6 @@ from .const import ( ATTR_LOCATION, ATTR_LOCK_STATE, ATTR_PET_NAME, - CONF_FEEDERS, - CONF_FLAPS, - CONF_PETS, DOMAIN, SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, @@ -49,66 +33,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=3) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_FEEDERS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_FLAPS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_PETS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - }, - cv.deprecated(CONF_FEEDERS), - cv.deprecated(CONF_FLAPS), - cv.deprecated(CONF_PETS), - cv.deprecated(CONF_SCAN_INTERVAL), - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Sure Petcare integration.""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Sure Petcare", - }, - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" @@ -178,7 +102,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): +class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): # pylint: disable=hass-enforce-coordinator-module """Handle Surepetcare data.""" def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 38bed2e20a9..81607b582c1 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -51,10 +51,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self._username: str | None = None - async def async_step_import(self, import_info: dict[str, Any] | None) -> FlowResult: - """Set the config entry up from yaml.""" - return await self.async_step_user(import_info) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index a510b5b7414..d87b711e376 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -89,7 +89,9 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_remove_device(dev.id) + device_registry.async_update_device( + dev.id, remove_config_entry_id=config_entry.entry_id + ) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" @@ -105,12 +107,13 @@ async def async_migrate_entry( ) # Set a valid unique id for config entries - config_entry.unique_id = new_unique_id config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry) + hass.config_entries.async_update_entry(config_entry, unique_id=new_unique_id) _LOGGER.debug( - "Migration to minor version %s successful", config_entry.minor_version + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, ) return True diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index ceb6f46806d..e864f31cd6c 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -39,12 +39,10 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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], - } + await self.async_set_unique_id( + f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" ) + self._abort_if_unique_id_configured() session = async_get_clientsession(self.hass) opendata = OpendataTransport( @@ -60,9 +58,6 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: - await self.async_set_unique_id( - f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" - ) return self.async_create_entry( title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", data=user_input, @@ -77,12 +72,10 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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], - } + await self.async_set_unique_id( + f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" ) + self._abort_if_unique_id_configured() session = async_get_clientsession(self.hass) opendata = OpendataTransport( @@ -102,9 +95,6 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="unknown") - await self.async_set_unique_id( - f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" - ) return self.async_create_entry( title=import_input[CONF_NAME], data=import_input, diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 93b3312b099..97253d5776e 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for the swiss_public_transport integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import TypedDict @@ -21,9 +21,9 @@ _LOGGER = logging.getLogger(__name__) class DataConnection(TypedDict): """A connection data class.""" - departure: str - next_departure: str - next_on_departure: str + departure: datetime | None + next_departure: str | None + next_on_departure: str | None duration: str platform: str remaining_time: str @@ -58,18 +58,35 @@ class SwissPublicTransportDataUpdateCoordinator(DataUpdateCoordinator[DataConnec ) raise UpdateFailed from e - departure_time = dt_util.parse_datetime( - self._opendata.connections[0]["departure"] + departure_time = ( + dt_util.parse_datetime(self._opendata.connections[0]["departure"]) + if self._opendata.connections[0] is not None + else None ) + next_departure_time = ( + dt_util.parse_datetime(self._opendata.connections[1]["departure"]) + if self._opendata.connections[1] is not None + else None + ) + next_on_departure_time = ( + dt_util.parse_datetime(self._opendata.connections[2]["departure"]) + if self._opendata.connections[2] is not None + else None + ) + 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"], + departure=departure_time, + next_departure=next_departure_time.isoformat() + if next_departure_time is not None + else None, + next_on_departure=next_on_departure_time.isoformat() + if next_on_departure_time is not None + else None, train_number=self._opendata.connections[0]["number"], platform=self._opendata.connections[0]["platform"], transfers=self._opendata.connections[0]["transfers"], diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 0e88cd2d3ad..ede2798f675 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -1,14 +1,18 @@ """Support for transport.opendata.ch.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback @@ -107,6 +111,7 @@ class SwissPublicTransportSensor( _attr_icon = "mdi:bus" _attr_has_entity_name = True _attr_translation_key = "departure" + _attr_device_class = SensorDeviceClass.TIMESTAMP def __init__( self, @@ -143,6 +148,6 @@ class SwissPublicTransportSensor( } @property - def native_value(self) -> str: + def native_value(self) -> datetime | None: """Return the state of the sensor.""" return self.coordinator.data["departure"] diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json new file mode 100644 index 00000000000..00520914b9f --- /dev/null +++ b/homeassistant/components/switch/icons.json @@ -0,0 +1,24 @@ +{ + "entity_component": { + "_": { + "default": "mdi:toggle-switch-variant" + }, + "switch": { + "default": "mdi:toggle-switch-variant", + "state": { + "off": "mdi:toggle-switch-variant-off" + } + }, + "outlet": { + "default": "mdi:power-plug", + "state": { + "off": "mdi:power-plug-off" + } + } + }, + "services": { + "toggle": "mdi:toggle-switch-variant", + "turn_off": "mdi:toggle-switch-variant-off", + "turn_on": "mdi:toggle-switch-variant" + } +} diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index e2ad91e990e..3fe2ff7bc7d 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.helpers.typing import EventType -from .const import CONF_TARGET_DOMAIN +from .const import CONF_INVERT, CONF_TARGET_DOMAIN from .light import LightSwitch __all__ = ["LightSwitch"] @@ -91,6 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entity_id, async_registry_updated ) ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) device_id = async_add_to_device(hass, entry, entity_id) @@ -100,6 +101,36 @@ 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.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + options.setdefault(CONF_INVERT, False) + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry, options=options) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + 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( diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 90f6b985893..e40e247f105 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( wrapped_entity_config_entry_title, ) -from .const import CONF_TARGET_DOMAIN, DOMAIN +from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN TARGET_DOMAIN_OPTIONS = [ selector.SelectOptionDict(value=Platform.COVER, label="Cover"), @@ -32,6 +32,7 @@ CONFIG_FLOW = { vol.Required(CONF_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain=Platform.SWITCH), ), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), vol.Required(CONF_TARGET_DOMAIN): selector.SelectSelector( selector.SelectSelectorConfig(options=TARGET_DOMAIN_OPTIONS), ), @@ -40,11 +41,21 @@ CONFIG_FLOW = { ) } +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + vol.Schema({vol.Required(CONF_INVERT): selector.BooleanSelector()}) + ), +} + class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Switch as X.""" config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + VERSION = 1 + MINOR_VERSION = 2 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title and hide the wrapped entity if registered.""" diff --git a/homeassistant/components/switch_as_x/const.py b/homeassistant/components/switch_as_x/const.py index 4963d6fa60b..58ace36487a 100644 --- a/homeassistant/components/switch_as_x/const.py +++ b/homeassistant/components/switch_as_x/const.py @@ -4,4 +4,5 @@ from typing import Final DOMAIN: Final = "switch_as_x" +CONF_INVERT: Final = "invert" CONF_TARGET_DOMAIN: Final = "target_domain" diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index b7fe0fbf364..37071ac6771 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -23,7 +23,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -43,6 +44,7 @@ async def async_setup_entry( hass, config_entry.title, COVER_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ) -class CoverSwitch(BaseEntity, CoverEntity): +class CoverSwitch(BaseInvertableEntity, CoverEntity): """Represents a Switch as a Cover.""" _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -59,7 +61,7 @@ class CoverSwitch(BaseEntity, CoverEntity): """Open the cover.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -69,7 +71,7 @@ class CoverSwitch(BaseEntity, CoverEntity): """Close cover.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -87,4 +89,7 @@ class CoverSwitch(BaseEntity, CoverEntity): ): return - self._attr_is_closed = state.state != STATE_ON + if self._invert_state: + self._attr_is_closed = state.state == STATE_ON + else: + self._attr_is_closed = state.state != STATE_ON diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 52d58157e34..39c2a8cab60 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -106,7 +106,7 @@ class BaseEntity(Entity): registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, - {"entity_id": self._switch_entity_id}, + self.async_generate_entity_options(), ) if not self._is_new_entity or not ( @@ -141,6 +141,11 @@ class BaseEntity(Entity): copy_custom_name(wrapped_switch) copy_expose_settings() + @callback + def async_generate_entity_options(self) -> dict[str, Any]: + """Generate entity options.""" + return {"entity_id": self._switch_entity_id, "invert": False} + class BaseToggleEntity(BaseEntity, ToggleEntity): """Represents a Switch as a ToggleEntity.""" @@ -178,3 +183,25 @@ class BaseToggleEntity(BaseEntity, ToggleEntity): return self._attr_is_on = state.state == STATE_ON + + +class BaseInvertableEntity(BaseEntity): + """Represents a Switch as an X.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry_title: str, + domain: str, + invert: bool, + switch_entity_id: str, + unique_id: str, + ) -> None: + """Initialize Switch as an X.""" + super().__init__(hass, config_entry_title, domain, switch_entity_id, unique_id) + self._invert_state = invert + + @callback + def async_generate_entity_options(self) -> dict[str, Any]: + """Generate entity options.""" + return super().async_generate_entity_options() | {"invert": self._invert_state} diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 9e7606865a1..528825c0300 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -19,7 +19,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -39,6 +40,7 @@ async def async_setup_entry( hass, config_entry.title, LOCK_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -46,14 +48,14 @@ async def async_setup_entry( ) -class LockSwitch(BaseEntity, LockEntity): +class LockSwitch(BaseInvertableEntity, LockEntity): """Represents a Switch as a Lock.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -63,7 +65,7 @@ class LockSwitch(BaseEntity, LockEntity): """Unlock the lock.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -83,4 +85,7 @@ class LockSwitch(BaseEntity, LockEntity): # Logic is the same as the lock device class for binary sensors # on means open (unlocked), off means closed (locked) - self._attr_is_locked = state.state != STATE_ON + if self._invert_state: + self._attr_is_locked = state.state == STATE_ON + else: + self._attr_is_locked = state.state != STATE_ON diff --git a/homeassistant/components/switch_as_x/strings.json b/homeassistant/components/switch_as_x/strings.json index 10adfd7686e..81567ef9e40 100644 --- a/homeassistant/components/switch_as_x/strings.json +++ b/homeassistant/components/switch_as_x/strings.json @@ -6,7 +6,23 @@ "description": "Pick a switch that you want to show up in Home Assistant as a light, cover or anything else. The original switch will be hidden.", "data": { "entity_id": "Switch", + "invert": "Invert state", "target_domain": "New Type" + }, + "data_description": { + "invert": "Invert state, only supported for cover, lock and valve." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "invert": "[%key:component::switch_as_x::config::step::user::data::invert%]" + }, + "data_description": { + "invert": "[%key:component::switch_as_x::config::step::user::data_description::invert%]" } } } diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py index 3a9fbc16247..971338764a5 100644 --- a/homeassistant/components/switch_as_x/valve.py +++ b/homeassistant/components/switch_as_x/valve.py @@ -23,7 +23,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -43,6 +44,7 @@ async def async_setup_entry( hass, config_entry.title, VALVE_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ) -class ValveSwitch(BaseEntity, ValveEntity): +class ValveSwitch(BaseInvertableEntity, ValveEntity): """Represents a Switch as a Valve.""" _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE @@ -60,7 +62,7 @@ class ValveSwitch(BaseEntity, ValveEntity): """Open the valve.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -70,7 +72,7 @@ class ValveSwitch(BaseEntity, ValveEntity): """Close valve.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -88,4 +90,7 @@ class ValveSwitch(BaseEntity, ValveEntity): ): return - self._attr_is_closed = state.state != STATE_ON + if self._invert_state: + self._attr_is_closed = state.state == STATE_ON + else: + self._attr_is_closed = state.state != STATE_ON diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 8dd740262f9..1fc5cfcba12 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -1,4 +1,5 @@ """Support for SwitchBee climate.""" + from __future__ import annotations from typing import Any @@ -87,11 +88,9 @@ async def async_setup_entry( class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], ClimateEntity): """Representation of a SwitchBee climate.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) _attr_fan_modes = SUPPORTED_FAN_MODES _attr_target_temperature_step = 1 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -106,6 +105,13 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_attrs_from_coordinator() @callback diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 39f2a4aa6da..1965867887c 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -65,7 +65,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - self.hass.state == CoreState.running + self.hass.state is CoreState.running and self.device.poll_needed(seconds_since_last_poll) and bool( bluetooth.async_ble_device_from_address( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 35083c4b089..4883bf456c0 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -78,6 +78,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to open curtain %s", self._address) self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: @@ -85,6 +87,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to close the curtain %s", self._address) self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -92,6 +96,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to stop %s", self._address) self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -100,14 +106,18 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to move at %d %s", position, self._address) self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() self._attr_current_cover_position = self.parsed_data["position"] self._attr_is_closed = self.parsed_data["position"] <= 20 - self._attr_is_opening = self.parsed_data["inMotion"] + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index d3d84d2cd48..2f92726a6da 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.43.0"] + "requirements": ["PySwitchbot==0.44.0"] } diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 803669c806d..d184063939a 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -80,6 +80,7 @@ class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 21 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False async def _do_send_command( self, diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 1539c81331e..cb651e5c84f 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.3.0"] + "requirements": ["switchbot-api==2.0.0"] } diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 051c5d2b72a..79ef201efee 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -125,7 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SwitcherDataUpdateCoordinator( update_coordinator.DataUpdateCoordinator[SwitcherBase] -): +): # pylint: disable=hass-enforce-coordinator-module """Switcher device data update coordinator.""" def __init__( diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 272d3ccf6ef..01c4814f985 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -86,6 +86,7 @@ class SwitcherClimateEntity( _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote @@ -118,6 +119,10 @@ class SwitcherClimateEntity( if features["swing"] and not remote.separated_swing_command: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + # There is always support for off + minimum one other mode so no need to check + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_data(True) @callback diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index ef2fc3dc128..f49eb7feed1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -220,13 +220,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] if existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, data=config_data + reason = ( + "reauth_successful" if self.reauth_conf else "reconfigure_successful" + ) + return self.async_update_reload_and_abort( + existing_entry, data=config_data, reason=reason ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - if self.reauth_conf: - return self.async_abort(reason="reauth_successful") - return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=friendly_name or host, data=config_data) diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 32970bc4fe5..cd3cad8024e 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable import dataclasses from datetime import datetime import logging -from typing import Any +from typing import Any, Protocol import aiohttp import voluptuous as vol @@ -30,13 +30,22 @@ INFO_CALLBACK_TIMEOUT = 5 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +class SystemHealthProtocol(Protocol): + """Define the format of system_health platforms.""" + + def async_register( + self, hass: HomeAssistant, register: SystemHealthRegistration + ) -> None: + """Register system health callbacks.""" + + @bind_hass @callback def async_register_info( hass: HomeAssistant, domain: str, info_callback: Callable[[HomeAssistant], Awaitable[dict]], -): +) -> None: """Register an info callback. Deprecated. @@ -61,7 +70,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _register_system_health_platform(hass, integration_domain, platform): +async def _register_system_health_platform( + hass: HomeAssistant, integration_domain: str, platform: SystemHealthProtocol +) -> None: """Register a system health platform.""" platform.async_register(hass, SystemHealthRegistration(hass, integration_domain)) @@ -89,7 +100,7 @@ async def get_integration_info( @callback -def _format_value(val): +def _format_value(val: Any) -> Any: """Format a system health value.""" if isinstance(val, datetime): return {"value": val.isoformat(), "type": "date"} @@ -207,7 +218,7 @@ class SystemHealthRegistration: self, info_callback: Callable[[HomeAssistant], Awaitable[dict]], manage_url: str | None = None, - ): + ) -> None: """Register an info callback.""" self.info_callback = info_callback self.manage_url = manage_url diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index fab2b7ee291..3ede14a2ad6 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -13,10 +13,12 @@ import voluptuous as vol from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +KeyType = tuple[str, tuple[str, int], str | None] + CONF_MAX_ENTRIES = "max_entries" CONF_FIRE_EVENT = "fire_event" CONF_MESSAGE = "message" @@ -60,7 +62,7 @@ SERVICE_WRITE_SCHEMA = vol.Schema( def _figure_out_source( - record: logging.LogRecord, paths_re: re.Pattern + record: logging.LogRecord, paths_re: re.Pattern[str] ) -> tuple[str, int]: """Figure out where a log message came from.""" # If a stack trace exists, extract file names from the entire call stack. @@ -184,7 +186,7 @@ class LogEntry: self.count = 1 self.key = (self.name, source, self.root_cause) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """Convert object into dict to maintain backward compatibility.""" return { "name": self.name, @@ -198,10 +200,10 @@ class LogEntry: } -class DedupStore(OrderedDict): +class DedupStore(OrderedDict[KeyType, LogEntry]): """Data store to hold max amount of deduped entries.""" - def __init__(self, maxlen=50): + def __init__(self, maxlen: int = 50) -> None: """Initialize a new DedupStore.""" super().__init__() self.maxlen = maxlen @@ -227,7 +229,7 @@ class DedupStore(OrderedDict): # Removes the first record which should also be the oldest self.popitem(last=False) - def to_list(self): + def to_list(self) -> list[dict[str, Any]]: """Return reversed list of log entries - LIFO.""" return [value.to_dict() for value in reversed(self.values())] @@ -236,7 +238,11 @@ class LogErrorHandler(logging.Handler): """Log handler for error messages.""" def __init__( - self, hass: HomeAssistant, maxlen: int, fire_event: bool, paths_re: re.Pattern + self, + hass: HomeAssistant, + maxlen: int, + fire_event: bool, + paths_re: re.Pattern[str], ) -> None: """Initialize a new LogErrorHandler.""" super().__init__() @@ -276,7 +282,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = handler @callback - def _async_stop_handler(_) -> None: + def _async_stop_handler(_: Event) -> None: """Cleanup handler.""" logging.root.removeHandler(handler) del hass.data[DOMAIN] diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py index c92647f9c8e..798cb82f8ef 100644 --- a/homeassistant/components/systemmonitor/const.py +++ b/homeassistant/components/systemmonitor/const.py @@ -5,13 +5,37 @@ DOMAIN = "systemmonitor" CONF_INDEX = "index" CONF_PROCESS = "process" -NETWORK_TYPES = [ +NET_IO_TYPES = [ "network_in", "network_out", "throughput_network_in", "throughput_network_out", "packets_in", "packets_out", - "ipv4_address", - "ipv6_address", +] + +# There might be additional keys to be added for different +# platforms / hardware combinations. +# Taken from last version of "glances" integration before they moved to +# a generic temperature sensor logic. +# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199 +CPU_SENSOR_PREFIXES = [ + "amdgpu 1", + "aml_thermal", + "Core 0", + "Core 1", + "CPU Temperature", + "CPU", + "cpu-thermal 1", + "cpu_thermal 1", + "exynos-therm 1", + "Package id 0", + "Physical id 0", + "radeon 1", + "soc-thermal 1", + "soc_thermal 1", + "Tctl", + "cpu0-thermal", + "cpu0_thermal", + "k10temp 1", ] diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py new file mode 100644 index 00000000000..9143d31f163 --- /dev/null +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -0,0 +1,166 @@ +"""DataUpdateCoordinators for the System monitor integration.""" +from __future__ import annotations + +from abc import abstractmethod +from datetime import datetime +import logging +import os +from typing import NamedTuple, TypeVar + +import psutil +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + + +class VirtualMemory(NamedTuple): + """Represents virtual memory. + + psutil defines virtual memory by platform. + Create our own definition here to be platform independent. + """ + + total: float + available: float + percent: float + used: float + free: float + + +dataT = TypeVar( + "dataT", + bound=datetime + | dict[str, list[shwtemp]] + | dict[str, list[snicaddr]] + | dict[str, snetio] + | float + | list[psutil.Process] + | sswap + | VirtualMemory + | tuple[float, float, float] + | sdiskusage, +) + + +class MonitorCoordinator(DataUpdateCoordinator[dataT]): + """A System monitor Base Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, name: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"System Monitor {name}", + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + + async def _async_update_data(self) -> dataT: + """Fetch data.""" + return await self.hass.async_add_executor_job(self.update_data) + + @abstractmethod + def update_data(self) -> dataT: + """To be extended by data update coordinators.""" + + +class SystemMonitorDiskCoordinator(MonitorCoordinator[sdiskusage]): + """A System monitor Disk Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, name: str, argument: str) -> None: + """Initialize the disk coordinator.""" + super().__init__(hass, name) + self._argument = argument + + def update_data(self) -> sdiskusage: + """Fetch data.""" + try: + return psutil.disk_usage(self._argument) + except PermissionError as err: + raise UpdateFailed(f"No permission to access {self._argument}") from err + except OSError as err: + raise UpdateFailed(f"OS error for {self._argument}") from err + + +class SystemMonitorSwapCoordinator(MonitorCoordinator[sswap]): + """A System monitor Swap Data Update Coordinator.""" + + def update_data(self) -> sswap: + """Fetch data.""" + return psutil.swap_memory() + + +class SystemMonitorMemoryCoordinator(MonitorCoordinator[VirtualMemory]): + """A System monitor Memory Data Update Coordinator.""" + + def update_data(self) -> VirtualMemory: + """Fetch data.""" + memory = psutil.virtual_memory() + return VirtualMemory( + memory.total, memory.available, memory.percent, memory.used, memory.free + ) + + +class SystemMonitorNetIOCoordinator(MonitorCoordinator[dict[str, snetio]]): + """A System monitor Network IO Data Update Coordinator.""" + + def update_data(self) -> dict[str, snetio]: + """Fetch data.""" + return psutil.net_io_counters(pernic=True) + + +class SystemMonitorNetAddrCoordinator(MonitorCoordinator[dict[str, list[snicaddr]]]): + """A System monitor Network Address Data Update Coordinator.""" + + def update_data(self) -> dict[str, list[snicaddr]]: + """Fetch data.""" + return psutil.net_if_addrs() + + +class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float]]): + """A System monitor Load Data Update Coordinator.""" + + def update_data(self) -> tuple[float, float, float]: + """Fetch data.""" + return os.getloadavg() + + +class SystemMonitorProcessorCoordinator(MonitorCoordinator[float]): + """A System monitor Processor Data Update Coordinator.""" + + def update_data(self) -> float: + """Fetch data.""" + return psutil.cpu_percent(interval=None) + + +class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]): + """A System monitor Processor Data Update Coordinator.""" + + def update_data(self) -> datetime: + """Fetch data.""" + return dt_util.utc_from_timestamp(psutil.boot_time()) + + +class SystemMonitorProcessCoordinator(MonitorCoordinator[list[psutil.Process]]): + """A System monitor Process Data Update Coordinator.""" + + def update_data(self) -> list[psutil.Process]: + """Fetch data.""" + processes = psutil.process_iter() + return list(processes) + + +class SystemMonitorCPUtempCoordinator(MonitorCoordinator[dict[str, list[shwtemp]]]): + """A System monitor CPU Temperature Data Update Coordinator.""" + + def update_data(self) -> dict[str, list[shwtemp]]: + """Fetch data.""" + try: + return psutil.sensors_temperatures() + except AttributeError as err: + raise UpdateFailed("OS does not provide temperature sensors") from err diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 213fa9cf6be..b93bdefd838 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.7"] + "requirements": ["psutil==5.9.8"] } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 95437c7fa4c..e751ffebb12 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,17 +1,18 @@ """Support for monitoring the local system.""" from __future__ import annotations -import asyncio +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta -from functools import cache +from datetime import datetime +from functools import lru_cache import logging -import os import socket import sys -from typing import Any +import time +from typing import Any, Generic, Literal import psutil +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap import voluptuous as vol from homeassistant.components.sensor import ( @@ -26,7 +27,6 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_RESOURCES, CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_OFF, STATE_ON, @@ -35,31 +35,36 @@ from homeassistant.const import ( UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant 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, -) -from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 +from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES +from .coordinator import ( + MonitorCoordinator, + SystemMonitorBootTimeCoordinator, + SystemMonitorCPUtempCoordinator, + SystemMonitorDiskCoordinator, + SystemMonitorLoadCoordinator, + SystemMonitorMemoryCoordinator, + SystemMonitorNetAddrCoordinator, + SystemMonitorNetIOCoordinator, + SystemMonitorProcessCoordinator, + SystemMonitorProcessorCoordinator, + SystemMonitorSwapCoordinator, + VirtualMemory, + dataT, +) +from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" -if sys.maxsize > 2**32: - CPU_ICON = "mdi:cpu-64-bit" -else: - CPU_ICON = "mdi:cpu-32-bit" SENSOR_TYPE_NAME = 0 SENSOR_TYPE_UOM = 1 @@ -70,185 +75,312 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -@dataclass(frozen=True) -class SysMonitorSensorEntityDescription(SensorEntityDescription): - """Description for System Monitor sensor entities.""" +@lru_cache +def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: + """Return cpu icon.""" + if sys.maxsize > 2**32: + return "mdi:cpu-64-bit" + return "mdi:cpu-32-bit" + +def get_processor_temperature( + entity: SystemMonitorSensor[dict[str, list[shwtemp]]], +) -> float | None: + """Return processor temperature.""" + return read_cpu_temperature(entity.coordinator.data) + + +def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> str: + """Return process.""" + state = STATE_OFF + for proc in entity.coordinator.data: + try: + _LOGGER.debug("process %s for argument %s", proc.name(), entity.argument) + if entity.argument == proc.name(): + state = STATE_ON + break + except psutil.NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with ID: %s, old name: %s", + err.pid, + err.name, + ) + return state + + +def get_network(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return network in and out.""" + counters = entity.coordinator.data + if entity.argument in counters: + counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + return round(counter / 1024**2, 1) + return None + + +def get_packets(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return packets in and out.""" + counters = entity.coordinator.data + if entity.argument in counters: + return counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + return None + + +def get_throughput(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return network throughput in and out.""" + counters = entity.coordinator.data + state = None + if entity.argument in counters: + counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + now = time.monotonic() + if ( + (value := entity.value) + and (update_time := entity.update_time) + and value < counter + ): + state = round( + (counter - value) / 1000**2 / (now - update_time), + 3, + ) + entity.update_time = now + entity.value = counter + return state + + +def get_ip_address( + entity: SystemMonitorSensor[dict[str, list[snicaddr]]], +) -> str | None: + """Return network ip address.""" + addresses = entity.coordinator.data + if entity.argument in addresses: + for addr in addresses[entity.argument]: + if addr.family == IF_ADDRS_FAMILY[entity.entity_description.key]: + return addr.address + return None + + +@dataclass(frozen=True, kw_only=True) +class SysMonitorSensorEntityDescription(SensorEntityDescription, Generic[dataT]): + """Describes System Monitor sensor entities.""" + + value_fn: Callable[[SystemMonitorSensor[dataT]], StateType | datetime] mandatory_arg: bool = False + placeholder: str | None = None -SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { - "disk_free": SysMonitorSensorEntityDescription( +SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = { + "disk_free": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_free", - name="Disk free", + translation_key="disk_free", + placeholder="mount_point", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.free / 1024**3, 1), ), - "disk_use": SysMonitorSensorEntityDescription( + "disk_use": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_use", - name="Disk use", + translation_key="disk_use", + placeholder="mount_point", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.used / 1024**3, 1), ), - "disk_use_percent": SysMonitorSensorEntityDescription( + "disk_use_percent": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_use_percent", - name="Disk use (percent)", + translation_key="disk_use_percent", + placeholder="mount_point", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), - "ipv4_address": SysMonitorSensorEntityDescription( + "ipv4_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]]( key="ipv4_address", - name="IPv4 address", + translation_key="ipv4_address", + placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, + value_fn=get_ip_address, ), - "ipv6_address": SysMonitorSensorEntityDescription( + "ipv6_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]]( key="ipv6_address", - name="IPv6 address", + translation_key="ipv6_address", + placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, + value_fn=get_ip_address, ), - "last_boot": SysMonitorSensorEntityDescription( + "last_boot": SysMonitorSensorEntityDescription[datetime]( key="last_boot", - name="Last boot", + translation_key="last_boot", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda entity: entity.coordinator.data, ), - "load_15m": SysMonitorSensorEntityDescription( + "load_15m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_15m", - name="Load (15m)", - icon=CPU_ICON, + translation_key="load_15m", + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[2], 2), ), - "load_1m": SysMonitorSensorEntityDescription( + "load_1m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_1m", - name="Load (1m)", - icon=CPU_ICON, + translation_key="load_1m", + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[0], 2), ), - "load_5m": SysMonitorSensorEntityDescription( + "load_5m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_5m", - name="Load (5m)", - icon=CPU_ICON, + translation_key="load_5m", + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[1], 2), ), - "memory_free": SysMonitorSensorEntityDescription( + "memory_free": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_free", - name="Memory free", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.available / 1024**2, 1), ), - "memory_use": SysMonitorSensorEntityDescription( + "memory_use": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_use", - name="Memory use", + translation_key="memory_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round( + (entity.coordinator.data.total - entity.coordinator.data.available) + / 1024**2, + 1, + ), ), - "memory_use_percent": SysMonitorSensorEntityDescription( + "memory_use_percent": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_use_percent", - name="Memory use (percent)", + translation_key="memory_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), - "network_in": SysMonitorSensorEntityDescription( + "network_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="network_in", - name="Network in", + translation_key="network_in", + placeholder="interface", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_network, ), - "network_out": SysMonitorSensorEntityDescription( + "network_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="network_out", - name="Network out", + translation_key="network_out", + placeholder="interface", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_network, ), - "packets_in": SysMonitorSensorEntityDescription( + "packets_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="packets_in", - name="Packets in", + translation_key="packets_in", + placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_packets, ), - "packets_out": SysMonitorSensorEntityDescription( + "packets_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="packets_out", - name="Packets out", + translation_key="packets_out", + placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_packets, ), - "throughput_network_in": SysMonitorSensorEntityDescription( + "throughput_network_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="throughput_network_in", - name="Network throughput in", + translation_key="throughput_network_in", + placeholder="interface", native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, mandatory_arg=True, + value_fn=get_throughput, ), - "throughput_network_out": SysMonitorSensorEntityDescription( + "throughput_network_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="throughput_network_out", - name="Network throughput out", + translation_key="throughput_network_out", + placeholder="interface", native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, mandatory_arg=True, + value_fn=get_throughput, ), - "process": SysMonitorSensorEntityDescription( + "process": SysMonitorSensorEntityDescription[list[psutil.Process]]( key="process", - name="Process", - icon=CPU_ICON, + translation_key="process", + placeholder="process", + icon=get_cpu_icon(), mandatory_arg=True, + value_fn=get_process, ), - "processor_use": SysMonitorSensorEntityDescription( + "processor_use": SysMonitorSensorEntityDescription[float]( key="processor_use", - name="Processor use", + translation_key="processor_use", native_unit_of_measurement=PERCENTAGE, - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data), ), - "processor_temperature": SysMonitorSensorEntityDescription( + "processor_temperature": SysMonitorSensorEntityDescription[ + dict[str, list[shwtemp]] + ]( key="processor_temperature", - name="Processor temperature", + translation_key="processor_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + value_fn=get_processor_temperature, ), - "swap_free": SysMonitorSensorEntityDescription( + "swap_free": SysMonitorSensorEntityDescription[sswap]( key="swap_free", - name="Swap free", + translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.free / 1024**2, 1), ), - "swap_use": SysMonitorSensorEntityDescription( + "swap_use": SysMonitorSensorEntityDescription[sswap]( key="swap_use", - name="Swap use", + translation_key="swap_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.used / 1024**2, 1), ), - "swap_use_percent": SysMonitorSensorEntityDescription( + "swap_use_percent": SysMonitorSensorEntityDescription[sswap]( key="swap_use_percent", - name="Swap use (percent)", + translation_key="swap_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), } @@ -303,46 +435,8 @@ IO_COUNTER = { "throughput_network_out": 0, "throughput_network_in": 1, } - IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6} -# There might be additional keys to be added for different -# platforms / hardware combinations. -# Taken from last version of "glances" integration before they moved to -# a generic temperature sensor logic. -# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199 -CPU_SENSOR_PREFIXES = [ - "amdgpu 1", - "aml_thermal", - "Core 0", - "Core 1", - "CPU Temperature", - "CPU", - "cpu-thermal 1", - "cpu_thermal 1", - "exynos-therm 1", - "Package id 0", - "Physical id 0", - "radeon 1", - "soc-thermal 1", - "soc_thermal 1", - "Tctl", - "cpu0-thermal", - "cpu0_thermal", - "k10temp 1", -] - - -@dataclass -class SensorData: - """Data for a sensor.""" - - argument: Any - state: str | datetime | None - value: Any | None - update_time: datetime | None - last_exception: BaseException | None - async def async_setup_platform( hass: HomeAssistant, @@ -382,33 +476,69 @@ async def async_setup_platform( ) -async def async_setup_entry( +async def async_setup_entry( # noqa: C901 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] = {} + entities: list[SystemMonitorSensor] = [] 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) + + def get_arguments() -> dict[str, Any]: + """Return startup information.""" + disk_arguments = get_all_disk_mounts() + network_arguments = get_all_network_interfaces() + cpu_temperature = read_cpu_temperature() + return { + "disk_arguments": disk_arguments, + "network_arguments": network_arguments, + "cpu_temperature": cpu_temperature, + } + + startup_arguments = await hass.async_add_executor_job(get_arguments) + + disk_coordinators: dict[str, SystemMonitorDiskCoordinator] = {} + for argument in startup_arguments["disk_arguments"]: + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) + swap_coordinator = SystemMonitorSwapCoordinator(hass, "Swap coordinator") + memory_coordinator = SystemMonitorMemoryCoordinator(hass, "Memory coordinator") + net_io_coordinator = SystemMonitorNetIOCoordinator(hass, "Net IO coordnator") + net_addr_coordinator = SystemMonitorNetAddrCoordinator( + hass, "Net address coordinator" + ) + system_load_coordinator = SystemMonitorLoadCoordinator( + hass, "System load coordinator" + ) + processor_coordinator = SystemMonitorProcessorCoordinator( + hass, "Processor coordinator" + ) + boot_time_coordinator = SystemMonitorBootTimeCoordinator( + hass, "Boot time coordinator" + ) + process_coordinator = SystemMonitorProcessCoordinator(hass, "Process coordinator") + cpu_temp_coordinator = SystemMonitorCPUtempCoordinator( + hass, "CPU temperature coordinator" + ) + + for argument in startup_arguments["disk_arguments"]: + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) _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 - ) + for argument in startup_arguments["disk_arguments"]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( - sensor_registry, + disk_coordinators[argument], sensor_description, entry.entry_id, argument, @@ -417,18 +547,15 @@ async def async_setup_entry( ) continue - if _type in NETWORK_TYPES: - for argument in network_arguments: - sensor_registry[(_type, argument)] = SensorData( - argument, None, None, None, None - ) + if _type.startswith("ipv"): + for argument in startup_arguments["network_arguments"]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(slugify(f"{_type}_{argument}")) + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( - sensor_registry, + net_addr_coordinator, sensor_description, entry.entry_id, argument, @@ -437,22 +564,74 @@ async def async_setup_entry( ) 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 cpu_temperature is None: - _LOGGER.warning("Cannot read CPU / processor temperature information") + if _type == "last_boot": + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + boot_time_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("load_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + system_load_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("memory_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + memory_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + + if _type in NET_IO_TYPES: + for argument in startup_arguments["network_arguments"]: + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + net_io_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) continue if _type == "process": - _entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {}) + _entry = 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(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( - sensor_registry, + process_coordinator, sensor_description, entry.entry_id, argument, @@ -461,18 +640,52 @@ async def async_setup_entry( ) 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_description, - entry.entry_id, - "", - is_enabled, + if _type == "processor_use": + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + processor_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type == "processor_temperature": + if not startup_arguments["cpu_temperature"]: + # Don't load processor temperature sensor if we can't read it. + continue + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + cpu_temp_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("swap_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + swap_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) ) - ) # Ensure legacy imported disk_* resources are loaded if they are not part # of mount points automatically discovered @@ -489,12 +702,13 @@ async def async_setup_entry( _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 - ) + if not disk_coordinators.get(argument): + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) entities.append( SystemMonitorSensor( - sensor_registry, + disk_coordinators[argument], SENSOR_TYPES[_type], entry.entry_id, argument, @@ -502,91 +716,45 @@ async def async_setup_entry( ) ) - scan_interval = DEFAULT_SCAN_INTERVAL - await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) + # No gathering to avoid swamping the executor + for coordinator in disk_coordinators.values(): + await coordinator.async_request_refresh() + await boot_time_coordinator.async_request_refresh() + await cpu_temp_coordinator.async_request_refresh() + await memory_coordinator.async_request_refresh() + await net_addr_coordinator.async_request_refresh() + await net_io_coordinator.async_request_refresh() + await process_coordinator.async_request_refresh() + await processor_coordinator.async_request_refresh() + await swap_coordinator.async_request_refresh() + await system_load_coordinator.async_request_refresh() + async_add_entities(entities) -async def async_setup_sensor_registry_updates( - hass: HomeAssistant, - sensor_registry: dict[tuple[str, str], SensorData], - scan_interval: timedelta, -) -> None: - """Update the registry and create polling.""" - - _update_lock = asyncio.Lock() - - def _update_sensors() -> None: - """Update sensors and store the result in the registry.""" - for (type_, argument), data in sensor_registry.items(): - try: - state, value, update_time = _update(type_, data) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Error updating sensor: %s (%s)", type_, argument) - data.last_exception = ex - else: - data.state = state - data.value = value - data.update_time = update_time - data.last_exception = None - - # Only fetch these once per iteration as we use the same - # data source multiple times in _update - _disk_usage.cache_clear() - _swap_memory.cache_clear() - _virtual_memory.cache_clear() - _net_io_counters.cache_clear() - _net_if_addrs.cache_clear() - _getloadavg.cache_clear() - - async def _async_update_data(*_: Any) -> None: - """Update all sensors in one executor jump.""" - if _update_lock.locked(): - _LOGGER.warning( - ( - "Updating systemmonitor took longer than the scheduled update" - " interval %s" - ), - scan_interval, - ) - return - - async with _update_lock: - await hass.async_add_executor_job(_update_sensors) - async_dispatcher_send(hass, SIGNAL_SYSTEMMONITOR_UPDATE) - - polling_remover = async_track_time_interval(hass, _async_update_data, scan_interval) - - @callback - def _async_stop_polling(*_: Any) -> None: - polling_remover() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_polling) - - await _async_update_data() - - -class SystemMonitorSensor(SensorEntity): +class SystemMonitorSensor(CoordinatorEntity[MonitorCoordinator[dataT]], SensorEntity): """Implementation of a system monitor sensor.""" - should_poll = False _attr_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: SysMonitorSensorEntityDescription[dataT] def __init__( self, - sensor_registry: dict[tuple[str, str], SensorData], - sensor_description: SysMonitorSensorEntityDescription, + coordinator: MonitorCoordinator[dataT], + sensor_description: SysMonitorSensorEntityDescription[dataT], entry_id: str, - argument: str = "", + argument: str, legacy_enabled: bool = False, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = sensor_description - self._attr_name: str = f"{sensor_description.name} {argument}".rstrip() + if self.entity_description.placeholder: + self._attr_translation_placeholders = { + self.entity_description.placeholder: argument + } 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, @@ -594,173 +762,11 @@ class SystemMonitorSensor(SensorEntity): manufacturer="System Monitor", name="System Monitor", ) + self.argument = argument + self.value: int | None = None + self.update_time: float | None = None @property - def native_value(self) -> str | datetime | None: - """Return the state of the device.""" - return self.data.state - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.data.last_exception is None - - @property - def data(self) -> SensorData: - """Return registry entry for the data.""" - return self._sensor_registry[(self.entity_description.key, self._argument)] - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SYSTEMMONITOR_UPDATE, self.async_write_ha_state - ) - ) - - -def _update( # noqa: C901 - type_: str, data: SensorData -) -> tuple[str | datetime | None, str | None, datetime | None]: - """Get the latest system information.""" - state = None - value = None - update_time = None - - if type_ == "disk_use_percent": - state = _disk_usage(data.argument).percent - elif type_ == "disk_use": - state = round(_disk_usage(data.argument).used / 1024**3, 1) - elif type_ == "disk_free": - state = round(_disk_usage(data.argument).free / 1024**3, 1) - elif type_ == "memory_use_percent": - state = _virtual_memory().percent - elif type_ == "memory_use": - virtual_memory = _virtual_memory() - state = round((virtual_memory.total - virtual_memory.available) / 1024**2, 1) - elif type_ == "memory_free": - state = round(_virtual_memory().available / 1024**2, 1) - elif type_ == "swap_use_percent": - state = _swap_memory().percent - elif type_ == "swap_use": - state = round(_swap_memory().used / 1024**2, 1) - elif type_ == "swap_free": - state = round(_swap_memory().free / 1024**2, 1) - elif type_ == "processor_use": - state = round(psutil.cpu_percent(interval=None)) - elif type_ == "processor_temperature": - state = _read_cpu_temperature() - elif type_ == "process": - state = STATE_OFF - for proc in psutil.process_iter(): - try: - if data.argument == proc.name(): - state = STATE_ON - break - except psutil.NoSuchProcess as err: - _LOGGER.warning( - "Failed to load process with ID: %s, old name: %s", - err.pid, - err.name, - ) - elif type_ in ("network_out", "network_in"): - counters = _net_io_counters() - if data.argument in counters: - counter = counters[data.argument][IO_COUNTER[type_]] - state = round(counter / 1024**2, 1) - else: - state = None - elif type_ in ("packets_out", "packets_in"): - counters = _net_io_counters() - if data.argument in counters: - state = counters[data.argument][IO_COUNTER[type_]] - else: - state = None - elif type_ in ("throughput_network_out", "throughput_network_in"): - counters = _net_io_counters() - if data.argument in counters: - counter = counters[data.argument][IO_COUNTER[type_]] - now = dt_util.utcnow() - if data.value and data.value < counter: - state = round( - (counter - data.value) - / 1000**2 - / (now - (data.update_time or now)).total_seconds(), - 3, - ) - else: - state = None - update_time = now - value = counter - else: - state = None - elif type_ in ("ipv4_address", "ipv6_address"): - addresses = _net_if_addrs() - if data.argument in addresses: - for addr in addresses[data.argument]: - if addr.family == IF_ADDRS_FAMILY[type_]: - state = addr.address - else: - state = None - elif type_ == "last_boot": - # Only update on initial setup - if data.state is None: - state = dt_util.utc_from_timestamp(psutil.boot_time()) - else: - state = data.state - elif type_ == "load_1m": - state = round(_getloadavg()[0], 2) - elif type_ == "load_5m": - state = round(_getloadavg()[1], 2) - elif type_ == "load_15m": - state = round(_getloadavg()[2], 2) - - return state, value, update_time - - -@cache -def _disk_usage(path: str) -> Any: - return psutil.disk_usage(path) - - -@cache -def _swap_memory() -> Any: - return psutil.swap_memory() - - -@cache -def _virtual_memory() -> Any: - return psutil.virtual_memory() - - -@cache -def _net_io_counters() -> Any: - return psutil.net_io_counters(pernic=True) - - -@cache -def _net_if_addrs() -> Any: - return psutil.net_if_addrs() - - -@cache -def _getloadavg() -> tuple[float, float, float]: - return os.getloadavg() - - -def _read_cpu_temperature() -> float | None: - """Attempt to read CPU / processor temperature.""" - temps = psutil.sensors_temperatures() - - for name, entries in temps.items(): - for i, entry in enumerate(entries, start=1): - # In case the label is empty (e.g. on Raspberry PI 4), - # construct it ourself here based on the sensor key name. - _label = f"{name} {i}" if not entry.label else entry.label - # check both name and label because some systems embed cpu# in the - # name, which makes label not match because label adds cpu# at end. - if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES: - return round(entry.current, 1) - - return None + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 88ecad4b107..ff1fbc221ee 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -21,5 +21,81 @@ } } } + }, + "entity": { + "sensor": { + "disk_free": { + "name": "Disk free {mount_point}" + }, + "disk_use": { + "name": "Disk use {mount_point}" + }, + "disk_use_percent": { + "name": "Disk usage {mount_point}" + }, + "ipv4_address": { + "name": "IPv4 address {ip_address}" + }, + "ipv6_address": { + "name": "IPv6 address {ip_address}" + }, + "last_boot": { + "name": "Last boot" + }, + "load_15m": { + "name": "Load (15m)" + }, + "load_1m": { + "name": "Load (1m)" + }, + "load_5m": { + "name": "Load (5m)" + }, + "memory_free": { + "name": "Memory free" + }, + "memory_use": { + "name": "Memory use" + }, + "memory_use_percent": { + "name": "Memory usage" + }, + "network_in": { + "name": "Network in {interface}" + }, + "network_out": { + "name": "Network out {interface}" + }, + "packets_in": { + "name": "Packets in {interface}" + }, + "packets_out": { + "name": "Packets out {interface}" + }, + "throughput_network_in": { + "name": "Network throughput in {interface}" + }, + "throughput_network_out": { + "name": "Network throughput out {interface}" + }, + "process": { + "name": "Process {process}" + }, + "processor_use": { + "name": "Processor use" + }, + "processor_temperature": { + "name": "Processor temperature" + }, + "swap_free": { + "name": "Swap free" + }, + "swap_use": { + "name": "Swap use" + }, + "swap_use_percent": { + "name": "Swap usage" + } + } } } diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 75b437c19eb..11d8fa9c062 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -4,6 +4,9 @@ import logging import os import psutil +from psutil._common import shwtemp + +from .const import CPU_SENSOR_PREFIXES _LOGGER = logging.getLogger(__name__) @@ -61,3 +64,23 @@ def get_all_running_processes() -> set[str]: processes.add(proc.name()) _LOGGER.debug("Running processes: %s", ", ".join(processes)) return processes + + +def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float | None: + """Attempt to read CPU / processor temperature.""" + if not temps: + temps = psutil.sensors_temperatures() + entry: shwtemp + + _LOGGER.debug("CPU Temperatures: %s", temps) + for name, entries in temps.items(): + for i, entry in enumerate(entries, start=1): + # In case the label is empty (e.g. on Raspberry PI 4), + # construct it ourself here based on the sensor key name. + _label = f"{name} {i}" if not entry.label else entry.label + # check both name and label because some systems embed cpu# in the + # name, which makes label not match because label adds cpu# at end. + if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES: + return round(entry.current, 1) + + return None diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 1193638c10e..dd0d6a22a08 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -131,7 +131,10 @@ def create_climate_entity(tado, name: str, zone_id: int, device_info: dict): zone_type = capabilities["type"] support_flags = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) supported_hvac_modes = [ TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], @@ -221,6 +224,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_name = None _attr_translation_key = DOMAIN _available = False + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 3ec75dee4bf..eb57aeaec79 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -123,6 +123,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): """A Tado Device Tracker entity.""" _attr_should_poll = False + _attr_available = False def __init__( self, @@ -150,6 +151,17 @@ class TadoDeviceTrackerEntity(TrackerEntity): ) device = self._tado.data["mobile_device"][self._device_id] + self._attr_available = False + _LOGGER.debug( + "Tado device %s has geoTracking state %s", + device["name"], + device["settings"]["geoTrackingEnabled"], + ) + + if device["settings"]["geoTrackingEnabled"] is False: + return + + self._attr_available = True self._active = False if device.get("location") is not None and device["location"]["atHome"]: _LOGGER.debug("Tado device %s is at home", device["name"]) diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 532d784b190..417cfe939d4 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -2,6 +2,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from . import TadoConnector from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE @@ -11,7 +12,7 @@ class TadoDeviceEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device_info): + def __init__(self, device_info: dict[str, str]) -> None: """Initialize a Tado device.""" super().__init__() self._device_info = device_info @@ -34,7 +35,7 @@ class TadoHomeEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, tado): + def __init__(self, tado: TadoConnector) -> None: """Initialize a Tado home.""" super().__init__() self.home_name = tado.home_name @@ -54,7 +55,7 @@ class TadoZoneEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, zone_name, home_id, zone_id): + def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None: """Initialize a Tado zone.""" super().__init__() self.zone_name = zone_name diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 79fe565261b..0f3288ba904 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"], + "codeowners": ["@chiefdragon", "@erwindouna"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index a9647c7e6e5..4ff12a6e51d 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import TadoConnector from .const import ( CONDITIONS_MAP, DATA, @@ -60,14 +61,14 @@ def format_condition(condition: str) -> str: return condition -def get_tado_mode(data) -> str | None: +def get_tado_mode(data: dict[str, str]) -> str | None: """Return Tado Mode based on Presence attribute.""" if "presence" in data: return data["presence"] return None -def get_automatic_geofencing(data) -> bool: +def get_automatic_geofencing(data: dict[str, str]) -> bool: """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" if "presenceLocked" in data: if data["presenceLocked"]: @@ -76,7 +77,7 @@ def get_automatic_geofencing(data) -> bool: return False -def get_geofencing_mode(data) -> str: +def get_geofencing_mode(data: dict[str, str]) -> str: """Return Geofencing Mode based on Presence and Presence Locked attributes.""" tado_mode = "" tado_mode = data.get("presence", "unknown") @@ -240,7 +241,9 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription - def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: + def __init__( + self, tado: TadoConnector, entity_description: TadoSensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description super().__init__(tado) @@ -261,13 +264,13 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self._async_update_home_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_home_data() self.async_write_ha_state() @callback - def _async_update_home_data(self): + def _async_update_home_data(self) -> None: """Handle update callbacks.""" try: tado_weather_data = self._tado.data["weather"] @@ -294,9 +297,9 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def __init__( self, - tado, - zone_name, - zone_id, + tado: TadoConnector, + zone_name: str, + zone_id: int, entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" @@ -321,13 +324,13 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): self._async_update_zone_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_zone_data() self.async_write_ha_state() @callback - def _async_update_zone_data(self): + def _async_update_zone_data(self) -> None: """Handle update callbacks.""" try: tado_zone_data = self._tado.data["zone"][self.zone_id] diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index f4772050e5a..c7ceb88294a 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -9,7 +9,7 @@ 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] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 846f1194930..643363b1285 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady 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 00000000000..c17a296e219 --- /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 9036d92d6f1..79447d93e9e 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/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index ac93154388a..3f86ef03df7 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -1,22 +1,14 @@ """Ask tankerkoenig.de for petrol price information.""" from __future__ import annotations -import logging - -from requests.exceptions import RequestException - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import TankerkoenigDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -25,24 +17,18 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator = TankerkoenigDataUpdateCoordinator( + + coordinator = TankerkoenigDataUpdateCoordinator( hass, entry, - _LOGGER, name=entry.unique_id or DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) - - try: - setup_ok = await hass.async_add_executor_job(coordinator.setup) - except RequestException as err: - raise ConfigEntryNotReady from err - if not setup_ok: - _LOGGER.error("Could not setup integration") - return False - + await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 2cf8869fcae..640708e1cb4 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from aiotankerkoenig import PriceInfo, Station, Status + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -23,21 +25,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the tankerkoenig binary sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - stations = coordinator.stations.values() - entities = [] - for station in stations: - sensor = StationOpenBinarySensorEntity( + async_add_entities( + StationOpenBinarySensorEntity( station, coordinator, - coordinator.show_on_map, ) - entities.append(sensor) - _LOGGER.debug("Added sensors %s", entities) - - async_add_entities(entities) + for station in coordinator.stations.values() + ) class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): @@ -48,22 +44,21 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE def __init__( self, - station: dict, + station: Station, coordinator: TankerkoenigDataUpdateCoordinator, - show_on_map: bool, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, station) - self._station_id = station["id"] - self._attr_unique_id = f"{station['id']}_status" - if show_on_map: + self._station_id = station.id + self._attr_unique_id = f"{station.id}_status" + if coordinator.show_on_map: self._attr_extra_state_attributes = { - ATTR_LATITUDE: station["lat"], - ATTR_LONGITUDE: station["lng"], + ATTR_LATITUDE: station.lat, + ATTR_LONGITUDE: station.lng, } @property def is_on(self) -> bool | None: """Return true if the station is open.""" - data: dict = self.coordinator.data[self._station_id] - return data is not None and data.get("status") == "open" + data: PriceInfo = self.coordinator.data[self._station_id] + return data is not None and data.status == Status.OPEN diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 79f6349f0cb..9bdf5ef0fe0 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -4,7 +4,13 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pytankerkoenig import customException, getNearbyStations +from aiotankerkoenig import ( + GasType, + Sort, + Station, + Tankerkoenig, + TankerkoenigInvalidKeyError, +) import voluptuous as vol from homeassistant import config_entries @@ -18,8 +24,9 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, UnitOfLength, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback 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 ( LocationSelector, @@ -31,21 +38,18 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_ async def async_get_nearby_stations( - hass: HomeAssistant, data: Mapping[str, Any] -) -> dict[str, Any]: + tankerkoenig: Tankerkoenig, data: Mapping[str, Any] +) -> list[Station]: """Fetch nearby stations.""" - try: - return await hass.async_add_executor_job( - getNearbyStations, - data[CONF_API_KEY], + return await tankerkoenig.nearby_stations( + coordinates=( data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE], - data[CONF_RADIUS], - "all", - "dist", - ) - except customException as err: - return {"ok": False, "message": err, "exception": True} + ), + radius=data[CONF_RADIUS], + gas_type=GasType.ALL, + sort=Sort.DISTANCE, + ) class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -79,17 +83,25 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - data = await async_get_nearby_stations(self.hass, user_input) - if not data.get("ok"): + tankerkoenig = Tankerkoenig( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + try: + stations = await async_get_nearby_stations(tankerkoenig, user_input) + except TankerkoenigInvalidKeyError: return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} ) - if len(stations := data.get("stations", [])) == 0: + + # no stations found + if len(stations) == 0: return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"}) + for station in stations: - self._stations[station["id"]] = ( - f"{station['brand']} {station['street']} {station['houseNumber']} -" - f" ({station['dist']}km)" + self._stations[station.id] = ( + f"{station.brand} {station.street} {station.house_number} -" + f" ({station.distance}km)" ) self._data = user_input @@ -128,8 +140,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry user_input = {**entry.data, **user_input} - data = await async_get_nearby_stations(self.hass, user_input) - if not data.get("ok"): + + tankerkoenig = Tankerkoenig( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + try: + await async_get_nearby_stations(tankerkoenig, user_input) + except TankerkoenigInvalidKeyError: return self._show_form_reauth(user_input, {CONF_API_KEY: "invalid_auth"}) self.hass.config_entries.async_update_entry(entry, data=user_input) @@ -233,14 +251,22 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ) return self.async_create_entry(title="", data=user_input) - nearby_stations = await async_get_nearby_stations( - self.hass, self.config_entry.data + tankerkoenig = Tankerkoenig( + api_key=self.config_entry.data[CONF_API_KEY], + session=async_get_clientsession(self.hass), ) - if stations := nearby_stations.get("stations"): + try: + stations = await async_get_nearby_stations( + tankerkoenig, self.config_entry.data + ) + except TankerkoenigInvalidKeyError: + return self.async_show_form(step_id="init", errors={"base": "invalid_auth"}) + + if stations: for station in stations: - self._stations[station["id"]] = ( - f"{station['brand']} {station['street']} {station['houseNumber']} -" - f" ({station['dist']}km)" + self._stations[station.id] = ( + f"{station.brand} {station.street} {station.house_number} -" + f" ({station.distance}km)" ) # add possible extra selected stations from import diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 536875f5733..f1f200a5964 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -5,13 +5,21 @@ from datetime import timedelta import logging from math import ceil -import pytankerkoenig +from aiotankerkoenig import ( + PriceInfo, + Station, + Tankerkoenig, + TankerkoenigConnectionError, + TankerkoenigError, + TankerkoenigInvalidKeyError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_FUEL_TYPES, CONF_STATIONS @@ -25,7 +33,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, entry: ConfigEntry, - logger: logging.Logger, name: str, update_interval: int, ) -> None: @@ -33,50 +40,41 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, - logger=logger, + logger=_LOGGER, name=name, update_interval=timedelta(minutes=update_interval), ) - self._api_key: str = entry.data[CONF_API_KEY] self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self.stations: dict[str, dict] = {} + self.stations: dict[str, Station] = {} self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] - def setup(self) -> bool: + self._tankerkoenig = Tankerkoenig( + api_key=entry.data[CONF_API_KEY], session=async_get_clientsession(hass) + ) + + async def async_setup(self) -> None: """Set up the tankerkoenig API.""" for station_id in self._selected_stations: try: - station_data = pytankerkoenig.getStationData(self._api_key, station_id) - except pytankerkoenig.customException as err: - if any(x in str(err).lower() for x in ("api-key", "apikey")): - raise ConfigEntryAuthFailed(err) from err - station_data = { - "ok": False, - "message": err, - "exception": True, - } + station = await self._tankerkoenig.station_details(station_id) + except TankerkoenigInvalidKeyError as err: + raise ConfigEntryAuthFailed(err) from err + except (TankerkoenigError, TankerkoenigConnectionError) as err: + raise ConfigEntryNotReady(err) from err + + self.stations[station_id] = station - if not station_data["ok"]: - _LOGGER.error( - "Error when adding station %s:\n %s", - station_id, - station_data["message"], - ) - continue - self.add_station(station_data["station"]) if len(self.stations) > 10: _LOGGER.warning( "Found more than 10 stations to check. " "This might invalidate your api-key on the long run. " "Try using a smaller radius" ) - return True - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, PriceInfo]: """Get the latest data from tankerkoenig.de.""" - _LOGGER.debug("Fetching new data from tankerkoenig.de") station_ids = list(self.stations) prices = {} @@ -84,30 +82,9 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self.hass.async_add_executor_job( - pytankerkoenig.getPriceList, - self._api_key, - station_ids[index * 10 : (index + 1) * 10], + data = await self._tankerkoenig.prices( + station_ids[index * 10 : (index + 1) * 10] ) + prices.update(data) - _LOGGER.debug("Received data: %s", data) - if not data["ok"]: - raise UpdateFailed(data["message"]) - if "prices" not in data: - raise UpdateFailed( - "Did not receive price information from tankerkoenig.de" - ) - prices.update(data["prices"]) return prices - - def add_station(self, station: dict): - """Add fuel station to the entity list.""" - station_id = station["id"] - if station_id in self.stations: - _LOGGER.warning( - "Sensor for station with id %s was already created", station_id - ) - return - - self.stations[station_id] = station - _LOGGER.debug("add_station called for station: %s", station) diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py index 811ec07ef19..d5fd7c8cada 100644 --- a/homeassistant/components/tankerkoenig/diagnostics.py +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for Tankerkoenig.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -27,6 +28,9 @@ async def async_get_config_entry_diagnostics( diag_data = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": { + station_id: asdict(price_info) + for station_id, price_info in coordinator.data.items() + }, } return diag_data diff --git a/homeassistant/components/tankerkoenig/entity.py b/homeassistant/components/tankerkoenig/entity.py index 6fbd9057679..96dafa80580 100644 --- a/homeassistant/components/tankerkoenig/entity.py +++ b/homeassistant/components/tankerkoenig/entity.py @@ -1,4 +1,6 @@ """The tankerkoenig base entity.""" +from aiotankerkoenig import Station + from homeassistant.const import ATTR_ID from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -6,20 +8,22 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import TankerkoenigDataUpdateCoordinator -class TankerkoenigCoordinatorEntity(CoordinatorEntity): +class TankerkoenigCoordinatorEntity( + CoordinatorEntity[TankerkoenigDataUpdateCoordinator] +): """Tankerkoenig base entity.""" _attr_has_entity_name = True def __init__( - self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + self, coordinator: TankerkoenigDataUpdateCoordinator, station: Station ) -> None: """Initialize the Tankerkoenig base entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], + identifiers={(ATTR_ID, station.id)}, + name=f"{station.brand} {station.street} {station.house_number}", + model=station.brand, configuration_url="https://www.tankerkoenig.de", entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/tankerkoenig/icons.json b/homeassistant/components/tankerkoenig/icons.json new file mode 100644 index 00000000000..594e016b112 --- /dev/null +++ b/homeassistant/components/tankerkoenig/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "e5": { + "default": "mdi:gas-station" + }, + "e10": { + "default": "mdi:gas-station" + }, + "diesel": { + "default": "mdi:gas-station" + } + } + } +} diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 39351b9dd27..adea5b96490 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -1,10 +1,10 @@ { "domain": "tankerkoenig", "name": "Tankerkoenig", - "codeowners": ["@guillempages", "@mib1185"], + "codeowners": ["@guillempages", "@mib1185", "@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", - "loggers": ["pytankerkoenig"], - "requirements": ["pytankerkoenig==0.0.6"] + "loggers": ["aiotankerkoenig"], + "requirements": ["aiotankerkoenig==0.3.0"] } diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index c309536cb9c..c0394bd318f 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from aiotankerkoenig import GasType, PriceInfo, Station + from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO @@ -30,26 +32,28 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the tankerkoenig sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - stations = coordinator.stations.values() entities = [] - for station in stations: - for fuel in coordinator.fuel_types: - if fuel not in station: - _LOGGER.warning( - "Station %s does not offer %s fuel", station["id"], fuel + for station in coordinator.stations.values(): + for fuel in (GasType.E10, GasType.E5, GasType.DIESEL): + if getattr(station, fuel) is None: + _LOGGER.debug( + "Station %s %s (%s) does not offer %s fuel, skipping", + station.brand, + station.name, + station.id, + fuel, ) continue - sensor = FuelPriceSensor( - fuel, - station, - coordinator, - coordinator.show_on_map, + + entities.append( + FuelPriceSensor( + fuel, + station, + coordinator, + ) ) - entities.append(sensor) - _LOGGER.debug("Added sensors %s", entities) async_add_entities(entities) @@ -60,33 +64,36 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): _attr_attribution = ATTRIBUTION _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = CURRENCY_EURO - _attr_icon = "mdi:gas-station" - def __init__(self, fuel_type, station, coordinator, show_on_map): + def __init__( + self, + fuel_type: GasType, + station: Station, + coordinator: TankerkoenigDataUpdateCoordinator, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator, station) - self._station_id = station["id"] + self._station_id = station.id self._fuel_type = fuel_type self._attr_translation_key = fuel_type - self._attr_unique_id = f"{station['id']}_{fuel_type}" + self._attr_unique_id = f"{station.id}_{fuel_type}" attrs = { - ATTR_BRAND: station["brand"], + ATTR_BRAND: station.brand, ATTR_FUEL_TYPE: fuel_type, - ATTR_STATION_NAME: station["name"], - ATTR_STREET: station["street"], - ATTR_HOUSE_NUMBER: station["houseNumber"], - ATTR_POSTCODE: station["postCode"], - ATTR_CITY: station["place"], + ATTR_STATION_NAME: station.name, + ATTR_STREET: station.street, + ATTR_HOUSE_NUMBER: station.house_number, + ATTR_POSTCODE: station.post_code, + ATTR_CITY: station.place, } - if show_on_map: - attrs[ATTR_LATITUDE] = station["lat"] - attrs[ATTR_LONGITUDE] = station["lng"] + if coordinator.show_on_map: + attrs[ATTR_LATITUDE] = str(station.lat) + attrs[ATTR_LONGITUDE] = str(station.lng) self._attr_extra_state_attributes = attrs @property - def native_value(self): - """Return the state of the device.""" - # key Fuel_type is not available when the fuel station is closed, - # use "get" instead of "[]" to avoid exceptions - return self.coordinator.data[self._station_id].get(self._fuel_type) + def native_value(self) -> float: + """Return the current price for the fuel type.""" + info: PriceInfo = self.coordinator.data[self._station_id] + return getattr(info, self._fuel_type) diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py new file mode 100644 index 00000000000..a235f98433b --- /dev/null +++ b/homeassistant/components/technove/__init__.py @@ -0,0 +1,34 @@ +"""The TechnoVE 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 TechnoVEDataUpdateCoordinator + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up TechnoVE from a config entry.""" + coordinator = TechnoVEDataUpdateCoordinator(hass) + 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/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py new file mode 100644 index 00000000000..09bf08baad6 --- /dev/null +++ b/homeassistant/components/technove/binary_sensor.py @@ -0,0 +1,103 @@ +"""Support for TechnoVE binary sensor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from technove import Station as TechnoVEStation + +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 TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity + + +@dataclass(frozen=True, kw_only=True) +class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): + """Describes TechnoVE binary sensor entity.""" + + value_fn: Callable[[TechnoVEStation], bool | None] + + +BINARY_SENSORS = [ + TechnoVEBinarySensorDescription( + key="conflict_in_sharing_config", + translation_key="conflict_in_sharing_config", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.conflict_in_sharing_config, + ), + TechnoVEBinarySensorDescription( + key="in_sharing_mode", + translation_key="in_sharing_mode", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.in_sharing_mode, + ), + TechnoVEBinarySensorDescription( + key="is_battery_protected", + translation_key="is_battery_protected", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.is_battery_protected, + ), + TechnoVEBinarySensorDescription( + key="is_session_active", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda station: station.info.is_session_active, + ), + TechnoVEBinarySensorDescription( + key="is_static_ip", + translation_key="is_static_ip", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.is_static_ip, + ), + TechnoVEBinarySensorDescription( + key="update_available", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.UPDATE, + value_fn=lambda station: not station.info.is_up_to_date, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TechnoVEBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + ) + + +class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity): + """Defines a TechnoVE binary sensor entity.""" + + entity_description: TechnoVEBinarySensorDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVEBinarySensorDescription, + ) -> None: + """Initialize a TechnoVE binary sensor entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py new file mode 100644 index 00000000000..d85fd0ad152 --- /dev/null +++ b/homeassistant/components/technove/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for TechnoVE.""" + +from typing import Any + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError +import voluptuous as vol + +from homeassistant.components import onboarding, zeroconf +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for TechnoVE.""" + + VERSION = 1 + discovered_host: str + discovered_station: TechnoVEStation + + 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: + station = await self._async_get_station(user_input[CONF_HOST]) + except TechnoVEConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(station.info.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( + title=station.info.name, + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + # Abort quick if the device with provided mac is already configured + if mac := discovery_info.properties.get(CONF_MAC): + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.host} + ) + + self.discovered_host = discovery_info.host + try: + self.discovered_station = await self._async_get_station(discovery_info.host) + except TechnoVEConnectionError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(self.discovered_station.info.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.context.update( + { + "title_placeholders": {"name": self.discovered_station.info.name}, + } + ) + 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.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry( + title=self.discovered_station.info.name, + data={ + CONF_HOST: self.discovered_host, + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.discovered_station.info.name}, + ) + + async def _async_get_station(self, host: str) -> TechnoVEStation: + """Get information from a TechnoVE station.""" + api = TechnoVE(host, session=async_get_clientsession(self.hass)) + return await api.update() diff --git a/homeassistant/components/technove/const.py b/homeassistant/components/technove/const.py new file mode 100644 index 00000000000..6dd7d567353 --- /dev/null +++ b/homeassistant/components/technove/const.py @@ -0,0 +1,8 @@ +"""Constants for the TechnoVE integration.""" +from datetime import timedelta +import logging + +DOMAIN = "technove" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py new file mode 100644 index 00000000000..66ec7d979f3 --- /dev/null +++ b/homeassistant/components/technove/coordinator.py @@ -0,0 +1,40 @@ +"""DataUpdateCoordinator for TechnoVE.""" +from __future__ import annotations + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class TechnoVEDataUpdateCoordinator(DataUpdateCoordinator[TechnoVEStation]): + """Class to manage fetching TechnoVE data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global TechnoVE data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.technove = TechnoVE( + self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> TechnoVEStation: + """Fetch data from TechnoVE.""" + try: + station = await self.technove.update() + except TechnoVEError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + + return station diff --git a/homeassistant/components/technove/entity.py b/homeassistant/components/technove/entity.py new file mode 100644 index 00000000000..964f2941301 --- /dev/null +++ b/homeassistant/components/technove/entity.py @@ -0,0 +1,26 @@ +"""Entity for TechnoVE.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator + + +class TechnoVEEntity(CoordinatorEntity[TechnoVEDataUpdateCoordinator]): + """Defines a base TechnoVE entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TechnoVEDataUpdateCoordinator, key: str) -> None: + """Initialize a base TechnoVE entity.""" + super().__init__(coordinator) + info = self.coordinator.data.info + self._attr_unique_id = f"{info.mac_address}_{key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, info.mac_address)}, + identifiers={(DOMAIN, info.mac_address)}, + name=info.name, + manufacturer="TechnoVE", + model=f"TechnoVE i{info.max_station_current}", + sw_version=info.version, + ) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json new file mode 100644 index 00000000000..33739bbd867 --- /dev/null +++ b/homeassistant/components/technove/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "technove", + "name": "TechnoVE", + "codeowners": ["@Moustachauve"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/technove", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["python-technove==1.2.1"], + "zeroconf": ["_technove-stations._tcp.local."] +} diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py new file mode 100644 index 00000000000..e4d3822ee1b --- /dev/null +++ b/homeassistant/components/technove/sensor.py @@ -0,0 +1,152 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from technove import Station as TechnoVEStation, Status + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, +) +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 TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity + +STATUS_TYPE = [s.value for s in Status if s != Status.UNKNOWN] + + +@dataclass(frozen=True, kw_only=True) +class TechnoVESensorEntityDescription(SensorEntityDescription): + """Describes TechnoVE sensor entity.""" + + value_fn: Callable[[TechnoVEStation], StateType] + + +SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( + TechnoVESensorEntityDescription( + key="voltage_in", + translation_key="voltage_in", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_in, + ), + TechnoVESensorEntityDescription( + key="voltage_out", + translation_key="voltage_out", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_out, + ), + TechnoVESensorEntityDescription( + key="max_station_current", + translation_key="max_station_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.max_station_current, + ), + TechnoVESensorEntityDescription( + key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.current, + ), + TechnoVESensorEntityDescription( + key="energy_total", + translation_key="energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_total, + ), + TechnoVESensorEntityDescription( + key="energy_session", + translation_key="energy_session", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_session, + ), + TechnoVESensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.rssi, + ), + TechnoVESensorEntityDescription( + key="ssid", + translation_key="ssid", + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.network_ssid, + ), + TechnoVESensorEntityDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=STATUS_TYPE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.status.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TechnoVESensorEntity(coordinator, description) for description in SENSORS + ) + + +class TechnoVESensorEntity(TechnoVEEntity, SensorEntity): + """Defines a TechnoVE sensor entity.""" + + entity_description: TechnoVESensorEntityDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVESensorEntityDescription, + ) -> None: + """Initialize a TechnoVE sensor entity.""" + 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.coordinator.data) diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json new file mode 100644 index 00000000000..8a850ee610c --- /dev/null +++ b/homeassistant/components/technove/strings.json @@ -0,0 +1,71 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up your TechnoVE station to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TechnoVE station." + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the TechnoVE Station named `{name}` to Home Assistant?", + "title": "Discovered TechnoVE station" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "binary_sensor": { + "conflict_in_sharing_config": { + "name": "Conflict with power sharing mode" + }, + "in_sharing_mode": { + "name": "Power sharing mode" + }, + "is_battery_protected": { + "name": "Battery protected" + }, + "is_static_ip": { + "name": "Static IP" + } + }, + "sensor": { + "voltage_in": { + "name": "Input voltage" + }, + "voltage_out": { + "name": "Output voltage" + }, + "max_station_current": { + "name": "Max station current" + }, + "energy_total": { + "name": "Total energy usage" + }, + "energy_session": { + "name": "Last session energy usage" + }, + "ssid": { + "name": "Wi-Fi network name" + }, + "status": { + "name": "Status", + "state": { + "unplugged": "Unplugged", + "plugged_waiting": "Plugged, waiting", + "plugged_charging": "Plugged, charging" + } + } + } + } +} diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py new file mode 100644 index 00000000000..eeb0f8e0d5a --- /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 00000000000..7efa25fa245 --- /dev/null +++ b/homeassistant/components/tedee/binary_sensor.py @@ -0,0 +1,89 @@ +"""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() + ] + ) + + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + async_add_entities( + [ + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES + ] + ) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + + +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 00000000000..075a4c998ea --- /dev/null +++ b/homeassistant/components/tedee/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for Tedee integration.""" +from collections.abc import Mapping +from typing import Any + +from pytedee_async import ( + TedeeAuthException, + TedeeClient, + TedeeClientException, + TedeeDataUpdateException, + 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" + except TedeeDataUpdateException: + errors["base"] = "cannot_connect" + 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 00000000000..bac5bfaec44 --- /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 00000000000..c846f2a8d9a --- /dev/null +++ b/homeassistant/components/tedee/coordinator.py @@ -0,0 +1,136 @@ +"""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 +import homeassistant.helpers.device_registry as dr +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() + self._locks_last_update: set[int] = set() + self.new_lock_callbacks: list[Callable[[int], None]] = [] + + @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())), + ) + + self._async_add_remove_locks() + 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 + + def _async_add_remove_locks(self) -> None: + """Add new locks, remove non-existing locks.""" + if not self._locks_last_update: + self._locks_last_update = set(self.tedee_client.locks_dict) + + if ( + current_locks := set(self.tedee_client.locks_dict) + ) == self._locks_last_update: + return + + # remove old locks + if removed_locks := self._locks_last_update - current_locks: + _LOGGER.debug("Removed locks: %s", ", ".join(map(str, removed_locks))) + device_registry = dr.async_get(self.hass) + for lock_id in removed_locks: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, str(lock_id))} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + # add new locks + if new_locks := current_locks - self._locks_last_update: + _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) + for lock_id in new_locks: + for callback in self.new_lock_callbacks: + callback(lock_id) + + self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/diagnostics.py b/homeassistant/components/tedee/diagnostics.py new file mode 100644 index 00000000000..d17c4c335bc --- /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 00000000000..59e3354aa1a --- /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.get(self._lock.lock_id, self._lock) + 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 00000000000..1025942d787 --- /dev/null +++ b/homeassistant/components/tedee/lock.py @@ -0,0 +1,128 @@ +"""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)) + + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + if lock.is_enabled_pullspring: + async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) + else: + async_add_entities([TedeeLockEntity(lock, coordinator)]) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + + 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 00000000000..1776e3b7ab2 --- /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.13"] +} diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py new file mode 100644 index 00000000000..9880f73746d --- /dev/null +++ b/homeassistant/components/tedee/sensor.py @@ -0,0 +1,85 @@ +"""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() + ] + ) + + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + async_add_entities( + [ + TedeeSensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES + ] + ) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + + +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 00000000000..1f0a5f0dc7e --- /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/template/coordinator.py b/homeassistant/components/template/coordinator.py index 7f24fe731cc..5ac2b7efa67 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -42,7 +42,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def async_setup(self, hass_config: ConfigType) -> None: """Set up the trigger and create entities.""" - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await self._attach_triggers() else: self._unsub_start = self.hass.bus.async_listen_once( diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index e757f561a7e..3a3d0682805 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -2,11 +2,13 @@ from __future__ import annotations from datetime import date, datetime +import logging from typing import Any import voluptuous as vol from homeassistant.components.sensor import ( + ATTR_LAST_RESET, CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, @@ -15,6 +17,7 @@ from homeassistant.components.sensor import ( RestoreSensor, SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry @@ -41,6 +44,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import ( @@ -63,14 +67,29 @@ LEGACY_FIELDS = { } -SENSOR_SCHEMA = ( +def validate_last_reset(val): + """Run extra validation checks.""" + if ( + val.get(ATTR_LAST_RESET) is not None + and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL + ): + raise vol.Invalid( + "last_reset is only valid for template sensors with state_class 'total'" + ) + + return val + + +SENSOR_SCHEMA = vol.All( vol.Schema( { vol.Required(CONF_STATE): cv.template, + vol.Optional(ATTR_LAST_RESET): cv.template, } ) .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), + validate_last_reset, ) @@ -138,6 +157,8 @@ PLATFORM_SCHEMA = vol.All( extra_validation_checks, ) +_LOGGER = logging.getLogger(__name__) + @callback def _async_create_template_tracking_entities( @@ -236,6 +257,9 @@ class SensorTemplate(TemplateEntity, SensorEntity): self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) self._template: template.Template = config[CONF_STATE] + self._attr_last_reset_template: None | template.Template = config.get( + ATTR_LAST_RESET + ) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass @@ -247,9 +271,20 @@ class SensorTemplate(TemplateEntity, SensorEntity): self.add_template_attribute( "_attr_native_value", self._template, None, self._update_state ) + if self._attr_last_reset_template is not None: + self.add_template_attribute( + "_attr_last_reset", + self._attr_last_reset_template, + cv.datetime, + self._update_last_reset, + ) super()._async_setup_templates() + @callback + def _update_last_reset(self, result): + self._attr_last_reset = result + @callback def _update_state(self, result): super()._update_state(result) @@ -283,6 +318,13 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + + if (last_reset_template := config.get(ATTR_LAST_RESET)) is not None: + if last_reset_template.is_static: + self._static_rendered[ATTR_LAST_RESET] = last_reset_template.template + else: + self._to_render_simple.append(ATTR_LAST_RESET) + self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -310,6 +352,18 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Process new data.""" super()._process_data() + # Update last_reset + if ATTR_LAST_RESET in self._rendered: + parsed_timestamp = dt_util.parse_datetime(self._rendered[ATTR_LAST_RESET]) + if parsed_timestamp is None: + _LOGGER.warning( + "%s rendered invalid timestamp for last_reset attribute: %s", + self.entity_id, + self._rendered.get(ATTR_LAST_RESET), + ) + else: + self._attr_last_reset = parsed_timestamp + if ( state := self._rendered.get(CONF_STATE) ) is None or self.device_class not in ( diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 19ad9e5ddeb..79cd0289724 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -75,6 +75,7 @@ "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]", "update": "[%key:component::binary_sensor::entity_component::update::name%]", "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]", "window": "[%key:component::binary_sensor::entity_component::window::name%]" @@ -127,6 +128,7 @@ "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f9c61850e58..9d08980da32 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Mapping import contextlib import itertools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -56,6 +56,11 @@ from .const import ( CONF_PICTURE, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( @@ -294,7 +299,7 @@ class TemplateEntity(Entity): super().__init__("unknown.unknown", STATE_UNKNOWN) self.entity_id = None # type: ignore[assignment] - @property + @cached_property def name(self) -> str: """Name of this state.""" return "" diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 39083434e89..b98c4c6e428 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.1.0" + "Pillow==10.2.0" ] } diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 1b9433eb696..da1e974f6a0 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -29,6 +29,19 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITAL _LOGGER = logging.getLogger(__name__) +EVSE_STATE = { + 0: "booting", + 1: "not_connected", + 2: "connected", + 4: "ready", + 6: "negotiating", + 7: "error", + 8: "charging_finished", + 9: "waiting_car", + 10: "charging_reduced", + 11: "charging", +} + @dataclass(frozen=True) class WallConnectorSensorDescription( @@ -40,9 +53,20 @@ class WallConnectorSensorDescription( WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( key="evse_state", - translation_key="evse_state", + translation_key="status_code", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].evse_state, + entity_registry_enabled_default=False, + ), + WallConnectorSensorDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: EVSE_STATE.get( + data[WALLCONNECTOR_DATA_VITALS].evse_state + ), + options=list(EVSE_STATE.values()), + icon="mdi:ev-station", ), WallConnectorSensorDescription( key="handle_temp_c", @@ -125,9 +149,19 @@ WALL_CONNECTOR_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + WallConnectorSensorDescription( + key="session_energy_wh", + translation_key="session_energy_wh", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].session_energy_wh, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), WallConnectorSensorDescription( key="energy_kWh", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 97bac988d16..ed1878caecb 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -30,8 +30,23 @@ } }, "sensor": { - "evse_state": { - "name": "State" + "status": { + "name": "Status", + "state": { + "booting": "Booting", + "not_connected": "Vehicle not connected", + "connected": "Vehicle connected", + "ready": "Ready to charge", + "negociating": "Negociating connection", + "error": "Error", + "charging_finished": "Charging finished", + "waiting_car": "Waiting for car", + "charging_reduced": "Charging (reduced)", + "charging": "Charging" + } + }, + "status_code": { + "name": "Status code" }, "handle_temp_c": { "name": "Handle temperature" @@ -59,6 +74,9 @@ }, "voltage_c_v": { "name": "Phase C voltage" + }, + "session_energy_wh": { + "name": "Session energy" } } } diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py new file mode 100644 index 00000000000..fb74e905181 --- /dev/null +++ b/homeassistant/components/teslemetry/__init__.py @@ -0,0 +1,77 @@ +"""Teslemetry integration.""" +import asyncio +from typing import Final + +from tesla_fleet_api import Teslemetry, VehicleSpecific +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import TeslemetryVehicleDataCoordinator +from .models import TeslemetryVehicleData + +PLATFORMS: Final = [ + Platform.CLIMATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Teslemetry config.""" + + access_token = entry.data[CONF_ACCESS_TOKEN] + + # Create API connection + teslemetry = Teslemetry( + session=async_get_clientsession(hass), + access_token=access_token, + ) + try: + products = (await teslemetry.products())["response"] + except InvalidToken: + LOGGER.error("Access token is invalid, unable to connect to Teslemetry") + return False + except PaymentRequired: + LOGGER.error("Subscription required, unable to connect to Telemetry") + return False + except TeslaFleetError as e: + raise ConfigEntryNotReady from e + + # Create array of classes + data = [] + for product in products: + if "vin" not in product: + continue + vin = product["vin"] + + api = VehicleSpecific(teslemetry.vehicle, vin) + coordinator = TeslemetryVehicleDataCoordinator(hass, api) + data.append( + TeslemetryVehicleData( + api=api, + coordinator=coordinator, + vin=vin, + ) + ) + + # Do all coordinator first refresh simultaneously + await asyncio.gather( + *(vehicle.coordinator.async_config_entry_first_refresh() for vehicle in data) + ) + + # Setup Platforms + 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 Teslemetry 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/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py new file mode 100644 index 00000000000..748acbb8552 --- /dev/null +++ b/homeassistant/components/teslemetry/climate.py @@ -0,0 +1,134 @@ +"""Climate platform for Teslemetry integration.""" +from __future__ import annotations + +from typing import Any + +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, TeslemetryClimateSide +from .context import handle_command +from .entity import TeslemetryVehicleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Climate platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER) + for vehicle in data + ) + + +class TeslemetryClimateEntity(TeslemetryVehicleEntity, 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.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes = ["off", "keep", "dog", "camp"] + _enable_turn_on_off_backwards_compatibility = False + + @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(f"climate_state_{self.key}_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.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_start() + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_stop() + 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] + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_temps( + driver_temp=temp, + passenger_temp=temp, + ) + + self.set((f"climate_state_{self.key}_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.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_climate_keeper_mode( + climate_keeper_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/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py new file mode 100644 index 00000000000..64a279132ad --- /dev/null +++ b/homeassistant/components/teslemetry/config_flow.py @@ -0,0 +1,63 @@ +"""Config Flow for Teslemetry integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientConnectionError +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError +import voluptuous as vol + +from homeassistant import config_entries +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, LOGGER + +TESLEMETRY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "short_url": "teslemetry.com/console", + "url": "[teslemetry.com/console](https://teslemetry.com/console)", +} + + +class TeslemetryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Teslemetry API connection.""" + + VERSION = 1 + + 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: + teslemetry = Teslemetry( + session=async_get_clientsession(self.hass), + access_token=user_input[CONF_ACCESS_TOKEN], + ) + try: + await teslemetry.test() + except InvalidToken: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except PaymentRequired: + errors["base"] = "subscription_required" + except ClientConnectionError: + errors["base"] = "cannot_connect" + except TeslaFleetError as e: + LOGGER.exception(str(e)) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Teslemetry", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESLEMETRY_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py new file mode 100644 index 00000000000..9b31a3270ca --- /dev/null +++ b/homeassistant/components/teslemetry/const.py @@ -0,0 +1,31 @@ +"""Constants used by Teslemetry integration.""" +from __future__ import annotations + +from enum import StrEnum +import logging + +DOMAIN = "teslemetry" + +LOGGER = logging.getLogger(__package__) + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TeslemetryState(StrEnum): + """Teslemetry Vehicle States.""" + + ONLINE = "online" + ASLEEP = "asleep" + OFFLINE = "offline" + + +class TeslemetryClimateSide(StrEnum): + """Teslemetry Climate Keeper Modes.""" + + DRIVER = "driver_temp" + PASSENGER = "passenger_temp" diff --git a/homeassistant/components/teslemetry/context.py b/homeassistant/components/teslemetry/context.py new file mode 100644 index 00000000000..942f1ccdd4b --- /dev/null +++ b/homeassistant/components/teslemetry/context.py @@ -0,0 +1,16 @@ +"""Teslemetry context managers.""" + +from contextlib import contextmanager + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + + +@contextmanager +def handle_command(): + """Handle wake up and errors.""" + try: + yield + except TeslaFleetError as e: + raise HomeAssistantError("Teslemetry command failed") from e diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py new file mode 100644 index 00000000000..4f12b4a3111 --- /dev/null +++ b/homeassistant/components/teslemetry/coordinator.py @@ -0,0 +1,67 @@ +"""Teslemetry Data Coordinator.""" +from datetime import timedelta +from typing import Any + +from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline +from tesla_fleet_api.vehiclespecific import VehicleSpecific + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, TeslemetryState + +SYNC_INTERVAL = 60 + + +class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Teslemetry API.""" + + def __init__(self, hass: HomeAssistant, api: VehicleSpecific) -> None: + """Initialize Teslemetry Data Update Coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Vehicle", + update_interval=timedelta(seconds=SYNC_INTERVAL), + ) + self.api = api + + async def async_config_entry_first_refresh(self) -> None: + """Perform first refresh.""" + try: + response = await self.api.wake_up() + if response["response"]["state"] != TeslemetryState.ONLINE: + # The first refresh will fail, so retry later + raise ConfigEntryNotReady("Vehicle is not online") + except TeslaFleetError as e: + # The first refresh will also fail, so retry later + raise ConfigEntryNotReady from e + await super().async_config_entry_first_refresh() + + async def _async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Teslemetry API.""" + + try: + data = await self.api.vehicle_data() + except VehicleOffline: + self.data["state"] = TeslemetryState.OFFLINE + return self.data + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + return self._flatten(data["response"]) + + 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/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py new file mode 100644 index 00000000000..d8dcf9934cc --- /dev/null +++ b/homeassistant/components/teslemetry/entity.py @@ -0,0 +1,62 @@ +"""Teslemetry parent entity class.""" + +import asyncio +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS, TeslemetryState +from .coordinator import TeslemetryVehicleDataCoordinator +from .models import TeslemetryVehicleData + + +class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]): + """Parent class for Teslemetry Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + vehicle: TeslemetryVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + super().__init__(vehicle.coordinator) + self.key = key + self.api = vehicle.api + self._wakelock = vehicle.wakelock + + car_type = self.coordinator.data["vehicle_config_car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{vehicle.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=self.coordinator.data["vehicle_state_vehicle_name"], + model=MODELS.get(car_type, car_type), + sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0], + hw_version=self.coordinator.data["vehicle_config_driver_assist"], + serial_number=vehicle.vin, + ) + + async def wake_up_if_asleep(self) -> None: + """Wake up the vehicle if its asleep.""" + async with self._wakelock: + while self.coordinator.data["state"] != TeslemetryState.ONLINE: + state = (await self.api.wake_up())["response"]["state"] + self.coordinator.data["state"] = state + if state != TeslemetryState.ONLINE: + await asyncio.sleep(5) + + 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) + + 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/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json new file mode 100644 index 00000000000..c76ac6fb63a --- /dev/null +++ b/homeassistant/components/teslemetry/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "teslemetry", + "name": "Teslemetry", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/teslemetry", + "iot_class": "cloud_polling", + "loggers": ["tesla-fleet-api"], + "requirements": ["tesla-fleet-api==0.2.3"] +} diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py new file mode 100644 index 00000000000..e5b27fa9279 --- /dev/null +++ b/homeassistant/components/teslemetry/models.py @@ -0,0 +1,19 @@ +"""The Teslemetry integration models.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from tesla_fleet_api import VehicleSpecific + +from .coordinator import TeslemetryVehicleDataCoordinator + + +@dataclass +class TeslemetryVehicleData: + """Data for a vehicle in the Teslemetry integration.""" + + api: VehicleSpecific + coordinator: TeslemetryVehicleDataCoordinator + vin: str + wakelock = asyncio.Lock() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json new file mode 100644 index 00000000000..95b2266b2dd --- /dev/null +++ b/homeassistant/components/teslemetry/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "subscription_required": "Subscription required, please visit {short_url}", + "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 an access token from {url}." + } + } + }, + "entity": { + "climate": { + "driver_temp": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "keep": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + } + } +} diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index e4c0d5d5c66..594098cddfe 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -54,6 +54,12 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( key="charge_state_trip_charging", entity_category=EntityCategory.DIAGNOSTIC, ), + TessieBinarySensorEntityDescription( + key="charge_state_conn_charge_cable", + is_on=lambda x: x != "", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), TessieBinarySensorEntityDescription( key="climate_state_auto_seat_climate_left", device_class=BinarySensorDeviceClass.HEAT, @@ -130,6 +136,26 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieBinarySensorEntityDescription( + key="vehicle_state_df", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_dr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_pf", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_pr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 8d27305cb0b..8eb69d619ff 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -45,7 +45,10 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes: list = [ TessieClimateKeeper.OFF, @@ -53,6 +56,7 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): TessieClimateKeeper.DOG, TessieClimateKeeper.CAMP, ] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 7dea7e65555..591d4652274 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -61,3 +61,10 @@ class TessieCoverStates(IntEnum): CLOSED = 0 OPEN = 1 + + +class TessieChargeCableLockStates(StrEnum): + """Tessie Charge Cable Lock states.""" + + ENGAGED = "Engaged" + DISENGAGED = "Disengaged" diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index be80caf50cb..bfedd7eb43d 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -38,8 +38,9 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): 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"], + sw_version=coordinator.data["vehicle_state_car_version"].split(" ")[0], hw_version=coordinator.data["vehicle_config_driver_assist"], + serial_number=self.vin, ) @property diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index e8fb8930bbc..1a0d879cd79 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -3,14 +3,15 @@ from __future__ import annotations from typing import Any -from tessie_api import lock, unlock +from tessie_api import lock, open_unlock_charge_port, unlock from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TessieChargeCableLockStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -21,11 +22,15 @@ async def async_setup_entry( """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) + async_add_entities( + klass(vehicle.state_coordinator) + for klass in (TessieLockEntity, TessieCableLockEntity) + for vehicle in data + ) class TessieLockEntity(TessieEntity, LockEntity): - """Lock entity for current charge.""" + """Lock entity for Tessie.""" def __init__( self, @@ -48,3 +53,32 @@ class TessieLockEntity(TessieEntity, LockEntity): """Set new value.""" await self.run(unlock) self.set((self.key, False)) + + +class TessieCableLockEntity(TessieEntity, LockEntity): + """Cable Lock entity for Tessie.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "charge_state_charge_port_latch") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value == TessieChargeCableLockStates.ENGAGED + + async def async_lock(self, **kwargs: Any) -> None: + """Charge cable Lock cannot be manually locked.""" + raise ServiceValidationError( + "Insert cable to lock", + translation_domain=DOMAIN, + translation_key="no_cable", + ) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock charge cable lock.""" + await self.run(open_unlock_charge_port) + self.set((self.key, TessieChargeCableLockStates.DISENGAGED)) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index aaf37e51d61..ae9e06b2b35 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,21 +24,33 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, ) -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 import dt as dt_util +from homeassistant.util.variance import ignore_variance from .const import DOMAIN from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity +@callback +def minutes_to_datetime(value: StateType) -> datetime | None: + """Convert relative minutes into absolute datetime.""" + if isinstance(value, (int, float)) and value > 0: + return dt_util.now() + timedelta(minutes=value) + return None + + @dataclass(frozen=True, kw_only=True) class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + available_fn: Callable[[StateType], bool] = lambda _: True DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( @@ -80,6 +94,12 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieSensorEntityDescription( + key="charge_state_minutes_to_full_charge", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=minutes_to_datetime, + ), TessieSensorEntityDescription( key="charge_state_battery_range", state_class=SensorStateClass.MEASUREMENT, @@ -181,6 +201,39 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( suggested_display_precision=1, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieSensorEntityDescription( + key="drive_state_active_route_traffic_minutes_delay", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_energy_at_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_miles_to_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_minutes_to_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=ignore_variance( + lambda value: dt_util.now() + timedelta(minutes=cast(float, value)), + timedelta(seconds=30), + ), + available_fn=lambda x: x is not None, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_destination", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), ) @@ -194,7 +247,6 @@ async def async_setup_entry( TessieSensorEntity(vehicle.state_coordinator, description) for vehicle in data for description in DESCRIPTIONS - if description.key in vehicle.state_coordinator.data ) @@ -213,6 +265,11 @@ class TessieSensorEntity(TessieEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" - return self.entity_description.value_fn(self._value) + return self.entity_description.value_fn(self.get()) + + @property + def available(self) -> bool: + """Return if sensor is available.""" + return super().available and self.entity_description.available_fn(self.get()) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 7cf511c125c..8340557843d 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -56,6 +56,9 @@ "lock": { "vehicle_state_locked": { "name": "[%key:component::lock::title%]" + }, + "charge_state_charge_port_latch": { + "name": "Charge cable lock" } }, "media_player": { @@ -85,6 +88,9 @@ "charge_state_battery_range": { "name": "Battery range" }, + "charge_state_minutes_to_full_charge": { + "name": "Time to full charge" + }, "drive_state_speed": { "name": "Speed" }, @@ -126,6 +132,21 @@ }, "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" + }, + "drive_state_active_route_traffic_minutes_delay": { + "name": "Traffic delay" + }, + "drive_state_active_route_energy_at_arrival": { + "name": "State of charge at arrival" + }, + "drive_state_active_route_miles_to_arrival": { + "name": "Distance to arrival" + }, + "drive_state_active_route_minutes_to_arrival": { + "name": "Time to arrival" + }, + "drive_state_active_route_destination": { + "name": "Destination" } }, "cover": { @@ -228,6 +249,9 @@ "charge_state_trip_charging": { "name": "Trip charging" }, + "charge_state_conn_charge_cable": { + "name": "Charge cable" + }, "climate_state_auto_seat_climate_left": { "name": "Auto seat climate left" }, @@ -272,6 +296,18 @@ }, "vehicle_state_rp_window": { "name": "Rear passenger window" + }, + "vehicle_state_df": { + "name": "Front driver door" + }, + "vehicle_state_pf": { + "name": "Front passenger door" + }, + "vehicle_state_dr": { + "name": "Rear driver door" + }, + "vehicle_state_pr": { + "name": "Rear passenger door" } }, "button": { @@ -315,5 +351,10 @@ "name": "[%key:component::update::title%]" } } + }, + "exceptions": { + "no_cable": { + "message": "Insert cable to lock" + } } } diff --git a/homeassistant/components/text/icons.json b/homeassistant/components/text/icons.json new file mode 100644 index 00000000000..355c439ec33 --- /dev/null +++ b/homeassistant/components/text/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:form-textbox" + } + }, + "services": { + "set_value": "mdi:form-textbox" + } +} diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 2e764b5c637..7e5999b7f02 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -83,8 +83,11 @@ class TfiacClimate(ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hass, client): """Init class.""" diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 6db5ff2a554..c6fb978923e 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -76,6 +76,8 @@ SENSOR_DESCRIPTIONS = { device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index a0a07d3cb00..817df22d6e1 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -9,12 +9,16 @@ { "local_name": "TP39*", "connectable": false + }, + { + "local_name": "TP96*", + "connectable": false } ], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco", "@h3ss"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.5.0"] + "requirements": ["thermopro-ble==0.9.0"] } diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 107385615f8..37cbf10323f 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -25,6 +25,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -59,6 +60,16 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + ( + ThermoProSensorDeviceClass.BATTERY, + Units.PERCENTAGE, + ): SensorEntityDescription( + key=f"{ThermoProSensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), } diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 9c5d79cc0e0..b5a3b39ae26 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from asyncio import Event, Task, wait import dataclasses from datetime import datetime import logging @@ -16,10 +17,13 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util, ulid as ulid_util +from . import discovery + +BORDER_AGENT_DISCOVERY_TIMEOUT = 30 DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) @@ -34,6 +38,7 @@ class DatasetEntry: """Dataset store entry.""" preferred_border_agent_id: str | None + preferred_extended_address: str | None source: str tlv: str @@ -75,6 +80,7 @@ class DatasetEntry: "created": self.created.isoformat(), "id": self.id, "preferred_border_agent_id": self.preferred_border_agent_id, + "preferred_extended_address": self.preferred_extended_address, "source": self.source, "tlv": self.tlv, } @@ -100,6 +106,7 @@ class DatasetStoreStore(Store): created=created, id=dataset["id"], preferred_border_agent_id=None, + preferred_extended_address=None, source=dataset["source"], tlv=dataset["tlv"], ) @@ -161,10 +168,14 @@ class DatasetStoreStore(Store): "preferred_dataset": preferred_dataset, "datasets": [dataset.to_json() for dataset in datasets.values()], } - if old_minor_version < 3: - # Add border agent ID + # Migration to version 1.3 removed, it added the ID of the preferred border + # agent + if old_minor_version < 4: + # Add extended address of the preferred border agent and clear border + # agent ID for dataset in data["datasets"]: - dataset.setdefault("preferred_border_agent_id", None) + dataset["preferred_border_agent_id"] = None + dataset["preferred_extended_address"] = None return data @@ -177,6 +188,7 @@ class DatasetStore: self.hass = hass self.datasets: dict[str, DatasetEntry] = {} self._preferred_dataset: str | None = None + self._set_preferred_dataset_task: Task | None = None self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, STORAGE_VERSION_MAJOR, @@ -187,7 +199,11 @@ class DatasetStore: @callback def async_add( - self, source: str, tlv: str, preferred_border_agent_id: str | None + self, + source: str, + tlv: str, + preferred_border_agent_id: str | None, + preferred_extended_address: str | None, ) -> None: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid @@ -201,16 +217,23 @@ class DatasetStore: ): raise HomeAssistantError("Invalid dataset") + # Don't allow setting preferred border agent ID without setting + # preferred extended address + if preferred_border_agent_id is not None and preferred_extended_address is None: + raise HomeAssistantError( + "Must set preferred extended address with preferred border agent ID" + ) + # Bail out if the dataset already exists entry: DatasetEntry | None for entry in self.datasets.values(): if entry.dataset == dataset: if ( - preferred_border_agent_id - and entry.preferred_border_agent_id is None + preferred_extended_address + and entry.preferred_extended_address is None ): - self.async_set_preferred_border_agent_id( - entry.id, preferred_border_agent_id + self.async_set_preferred_border_agent( + entry.id, preferred_border_agent_id, preferred_extended_address ) return @@ -257,21 +280,34 @@ class DatasetStore: self.datasets[entry.id], tlv=tlv ) self.async_schedule_save() - if preferred_border_agent_id and entry.preferred_border_agent_id is None: - self.async_set_preferred_border_agent_id( - entry.id, preferred_border_agent_id + if preferred_extended_address and entry.preferred_extended_address is None: + self.async_set_preferred_border_agent( + entry.id, preferred_border_agent_id, preferred_extended_address ) return entry = DatasetEntry( - preferred_border_agent_id=preferred_border_agent_id, source=source, tlv=tlv + preferred_border_agent_id=preferred_border_agent_id, + preferred_extended_address=preferred_extended_address, + source=source, + tlv=tlv, ) self.datasets[entry.id] = entry - # Set to preferred if there is no preferred dataset - if self._preferred_dataset is None: - self._preferred_dataset = entry.id self.async_schedule_save() + # Set the new network as preferred if there is no preferred dataset and there is + # no other router present. We only attempt this once. + if ( + self._preferred_dataset is None + and preferred_extended_address + and not self._set_preferred_dataset_task + ): + self._set_preferred_dataset_task = self.hass.async_create_task( + self._set_preferred_dataset_if_only_network( + entry.id, preferred_extended_address + ) + ) + @callback def async_delete(self, dataset_id: str) -> None: """Delete dataset.""" @@ -286,12 +322,21 @@ class DatasetStore: return self.datasets.get(dataset_id) @callback - def async_set_preferred_border_agent_id( - self, dataset_id: str, border_agent_id: str + def async_set_preferred_border_agent( + self, dataset_id: str, border_agent_id: str | None, extended_address: str ) -> None: - """Set preferred border agent id of a dataset.""" + """Set preferred border agent id and extended address of a dataset.""" + # Don't allow setting preferred border agent ID without setting + # preferred extended address + if border_agent_id is not None and extended_address is None: + raise HomeAssistantError( + "Must set preferred extended address with preferred border agent ID" + ) + self.datasets[dataset_id] = dataclasses.replace( - self.datasets[dataset_id], preferred_border_agent_id=border_agent_id + self.datasets[dataset_id], + preferred_border_agent_id=border_agent_id, + preferred_extended_address=extended_address, ) self.async_schedule_save() @@ -310,6 +355,62 @@ class DatasetStore: self._preferred_dataset = dataset_id self.async_schedule_save() + async def _set_preferred_dataset_if_only_network( + self, dataset_id: str, extended_address: str | None + ) -> None: + """Set the preferred dataset, unless there are other routers present.""" + _LOGGER.debug( + "_set_preferred_dataset_if_only_network called for router %s", + extended_address, + ) + + own_router_evt = Event() + other_router_evt = Event() + + @callback + def router_discovered( + key: str, data: discovery.ThreadRouterDiscoveryData + ) -> None: + """Handle router discovered.""" + _LOGGER.debug("discovered router with ext addr %s", data.extended_address) + if data.extended_address == extended_address: + own_router_evt.set() + return + + other_router_evt.set() + + # Start Thread router discovery + thread_discovery = discovery.ThreadRouterDiscovery( + self.hass, router_discovered, lambda key: None + ) + await thread_discovery.async_start() + + found_own_router = self.hass.async_create_task(own_router_evt.wait()) + found_other_router = self.hass.async_create_task(other_router_evt.wait()) + pending = {found_own_router, found_other_router} + (done, pending) = await wait(pending, timeout=BORDER_AGENT_DISCOVERY_TIMEOUT) + if found_other_router in done: + # We found another router on the network, don't set the dataset + # as preferred + _LOGGER.debug("Other router found, do not set dataset as default") + + # Note that asyncio.wait does not raise TimeoutError, it instead returns + # the jobs which did not finish in the pending-set. + elif found_own_router in pending: + # Either the router is not there, or mDNS is not working. In any case, + # don't set the router as preferred. + _LOGGER.debug("Own router not found, do not set dataset as default") + + else: + # We've discovered the router connected to the dataset, but we did not + # find any other router on the network - mark the dataset as preferred. + _LOGGER.debug("No other router found, set dataset as default") + self.preferred_dataset = dataset_id + + for task in pending: + task.cancel() + await thread_discovery.async_stop() + async def async_load(self) -> None: """Load the datasets.""" data = await self._store.async_load() @@ -324,6 +425,7 @@ class DatasetStore: created=created, id=dataset["id"], preferred_border_agent_id=dataset["preferred_border_agent_id"], + preferred_extended_address=dataset["preferred_extended_address"], source=dataset["source"], tlv=dataset["tlv"], ) @@ -360,10 +462,11 @@ async def async_add_dataset( tlv: str, *, preferred_border_agent_id: str | None = None, + preferred_extended_address: str | None = None, ) -> None: """Add a dataset.""" store = await async_get_store(hass) - store.async_add(source, tlv, preferred_border_agent_id) + store.async_add(source, tlv, preferred_border_agent_id, preferred_extended_address) async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 0f2997986cb..ad1df757af4 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -16,11 +16,14 @@ from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { + "Amazon": "amazon", "Apple Inc.": "apple", "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", + "Nanoleaf": "nanoleaf", + "OpenThread": "openthread", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index eeac24a626f..65d4c9d044c 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/strings.json b/homeassistant/components/thread/strings.json index 0a9cf0004bc..474999b06bd 100644 --- a/homeassistant/components/thread/strings.json +++ b/homeassistant/components/thread/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, "step": { "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5b289cf1694..9dd1971f91c 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,7 +20,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) - websocket_api.async_register_command(hass, ws_set_preferred_border_agent_id) + websocket_api.async_register_command(hass, ws_set_preferred_border_agent) websocket_api.async_register_command(hass, ws_set_preferred_dataset) @@ -54,20 +54,24 @@ async def ws_add_dataset( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "thread/set_preferred_border_agent_id", + vol.Required("type"): "thread/set_preferred_border_agent", vol.Required("dataset_id"): str, - vol.Required("border_agent_id"): str, + vol.Required("border_agent_id"): vol.Any(str, None), + vol.Required("extended_address"): str, } ) @websocket_api.async_response -async def ws_set_preferred_border_agent_id( +async def ws_set_preferred_border_agent( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Set the preferred border agent ID.""" + """Set the preferred border agent's border agent ID and extended address.""" dataset_id = msg["dataset_id"] border_agent_id = msg["border_agent_id"] + extended_address = msg["extended_address"] store = await dataset_store.async_get_store(hass) - store.async_set_preferred_border_agent_id(dataset_id, border_agent_id) + store.async_set_preferred_border_agent( + dataset_id, border_agent_id, extended_address + ) connection.send_result(msg["id"]) @@ -174,6 +178,7 @@ async def ws_list_datasets( "pan_id": dataset.pan_id, "preferred": dataset.id == preferred_dataset, "preferred_border_agent_id": dataset.preferred_border_agent_id, + "preferred_extended_address": dataset.preferred_extended_address, "source": dataset.source, } ) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 2694ef50e3a..467cd2bfd77 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -498,7 +498,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) self.async_write_ha_state() -class TibberRtDataCoordinator(DataUpdateCoordinator): +class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Handle Tibber realtime data.""" def __init__( @@ -562,7 +562,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): return self.data.get("data", {}).get("liveMeasurement") -class TibberDataCoordinator(DataUpdateCoordinator[None]): +class TibberDataCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Handle Tibber data and insert statistics.""" config_entry: ConfigEntry diff --git a/homeassistant/components/time/icons.json b/homeassistant/components/time/icons.json new file mode 100644 index 00000000000..c08e457e04d --- /dev/null +++ b/homeassistant/components/time/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:clock" + } + }, + "services": { + "set_value": "mdi:clock-edit" + } +} diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 25e6fa14f39..cdd69a2bc1f 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -1 +1,18 @@ """The time_date component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Time & Date 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 Time & Date config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py new file mode 100644 index 00000000000..09a5f2503d0 --- /dev/null +++ b/homeassistant/components/time_date/config_flow.py @@ -0,0 +1,130 @@ +"""Adds config flow for Time & Date integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.setup import async_prepare_setup_platform + +from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES +from .sensor import TimeDateSensor + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( + SelectSelectorConfig( + options=[option for option in OPTION_TYPES if option != "beat"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="display_options", + ) + ), + } +) + + +async def validate_input( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate rest setup.""" + hass = handler.parent_handler.hass + if hass.config.time_zone is None: + raise SchemaFlowError("timezone_not_exist") + return user_input + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=USER_SCHEMA, + preview=DOMAIN, + validate_user_input=validate_input, + ) +} + + +class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Time & Date.""" + + config_flow = CONFIG_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return f"Time & Date {options[CONF_DISPLAY_OPTIONS]}" + + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Abort if instance already exist.""" + self._async_abort_entries_match(dict(options)) + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "time_date/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + validated = USER_SCHEMA(msg["user_input"]) + + # Create an EntityPlatform, needed for name translations + platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) + entity_platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=SENSOR_DOMAIN, + platform_name=DOMAIN, + platform=platform, + scan_interval=timedelta(seconds=3600), + entity_namespace=None, + ) + await entity_platform.async_load_translations() + + @callback + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: + """Forward config entry state events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], {"attributes": attributes, "state": state} + ) + ) + + preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) + preview_entity.hass = hass + preview_entity.platform = entity_platform + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py new file mode 100644 index 00000000000..dde9497b9a3 --- /dev/null +++ b/homeassistant/components/time_date/const.py @@ -0,0 +1,22 @@ +"""Constants for the Time & Date integration.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.const import Platform + +CONF_DISPLAY_OPTIONS = "display_options" +DOMAIN: Final = "time_date" +PLATFORMS = [Platform.SENSOR] +TIME_STR_FORMAT = "%H:%M" + +OPTION_TYPES = [ + "time", + "date", + "date_time", + "date_time_utc", + "date_time_iso", + "time_date", + "beat", + "time_utc", +] diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json index 9d625b8587e..9247b60568a 100644 --- a/homeassistant/components/time_date/manifest.json +++ b/homeassistant/components/time_date/manifest.json @@ -2,7 +2,9 @@ "domain": "time_date", "name": "Time & Date", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/time_date", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 5646c7a7018..bd0f9449aea 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,34 +1,34 @@ """Support for showing the date and the time.""" from __future__ import annotations -from datetime import timedelta +from collections.abc import Callable, Mapping +from datetime import datetime, timedelta import logging +from typing import Any 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.components.sensor import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.config_entries import ConfigEntry +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, OPTION_TYPES + _LOGGER = logging.getLogger(__name__) TIME_STR_FORMAT = "%H:%M" -OPTION_TYPES = { - "time": "Time", - "date": "Date", - "date_time": "Date & Time", - "date_time_utc": "Date & Time (UTC)", - "date_time_iso": "Date & Time (ISO)", - "time_date": "Time & Date", - "beat": "Internet Time", - "time_utc": "Time (UTC)", -} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -47,39 +47,66 @@ 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]] + [TimeDateSensor(variable) for variable in config[CONF_DISPLAY_OPTIONS]] + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Time & Date sensor.""" + + async_add_entities( + [TimeDateSensor(entry.options[CONF_DISPLAY_OPTIONS], entry.entry_id)] ) class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" - def __init__(self, hass, option_type): + _attr_should_poll = False + _attr_has_entity_name = True + _state: str | None = None + unsub: CALLBACK_TYPE | None = None + + def __init__(self, option_type: str, entry_id: str | None = None) -> None: """Initialize the sensor.""" - self._name = OPTION_TYPES[option_type] + self._attr_translation_key = option_type self.type = option_type - self._state = None - self.hass = hass - self.unsub = None + object_id = "internet_time" if option_type == "beat" else option_type + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._attr_unique_id = option_type if entry_id else None self._update_internal_state(dt_util.utcnow()) @property - def name(self): - """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" @@ -87,11 +114,47 @@ class TimeDateSensor(SensorEntity): return "mdi:calendar" return "mdi:clock" + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def point_in_time_listener(time_date: datetime | None) -> None: + """Update preview.""" + + now = dt_util.utcnow() + self._update_internal_state(now) + self.unsub = async_track_point_in_utc_time( + self.hass, point_in_time_listener, self.get_next_interval(now) + ) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) + + @callback + def async_stop_preview() -> None: + """Stop preview.""" + if self.unsub: + self.unsub() + self.unsub = None + + point_in_time_listener(None) + return async_stop_preview + 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 +162,27 @@ class TimeDateSensor(SensorEntity): 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 +216,20 @@ class TimeDateSensor(SensorEntity): 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 00000000000..e9efe949b9b --- /dev/null +++ b/homeassistant/components/time_date/strings.json @@ -0,0 +1,83 @@ +{ + "title": "Time & Date", + "config": { + "abort": { + "already_configured": "The chosen Time & Date sensor has already been configured" + }, + "step": { + "user": { + "description": "Select from the sensor options below", + "data": { + "display_options": "Sensor type" + } + } + }, + "error": { + "timezone_not_exist": "Timezone is not set in Home Assistant configuration" + } + }, + "options": { + "step": { + "init": { + "data": { + "display_options": "[%key:component::time_date::config::step::user::data::display_options%]" + } + } + } + }, + "selector": { + "display_options": { + "options": { + "time": "Time", + "date": "Date", + "date_time": "Date & Time", + "date_time_utc": "Date & Time (UTC)", + "date_time_iso": "Date & Time (ISO)", + "time_date": "Time & Date", + "beat": "Internet time", + "time_utc": "Time (UTC)" + } + } + }, + "entity": { + "sensor": { + "time": { + "name": "[%key:component::time_date::selector::display_options::options::time%]" + }, + "date": { + "name": "[%key:component::time_date::selector::display_options::options::date%]" + }, + "date_time": { + "name": "[%key:component::time_date::selector::display_options::options::date_time%]" + }, + "date_time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_utc%]" + }, + "date_time_iso": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_iso%]" + }, + "time_date": { + "name": "[%key:component::time_date::selector::display_options::options::time_date%]" + }, + "beat": { + "name": "[%key:component::time_date::selector::display_options::options::beat%]" + }, + "time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::time_utc%]" + } + } + }, + "issues": { + "deprecated_beat": { + "title": "The `{config_key}` Time & Date sensor is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::time_date::issues::deprecated_beat::title%]", + "description": "Please remove the `{config_key}` key from the {integration} config entry options and click submit to fix this issue." + } + } + } + } + } +} diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 17712b6aef1..4c611962436 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Self +from typing import Any, Self, TypeVar import voluptuous as vol @@ -28,6 +28,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +_T = TypeVar("_T") _LOGGER = logging.getLogger(__name__) DOMAIN = "timer" @@ -73,14 +74,14 @@ STORAGE_FIELDS = { } -def _format_timedelta(delta: timedelta): +def _format_timedelta(delta: timedelta) -> str: total_seconds = delta.total_seconds() hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}" -def _none_to_empty_dict(value): +def _none_to_empty_dict(value: _T | None) -> _T | dict[Any, Any]: if value is None: return {} return value @@ -185,7 +186,7 @@ class TimerStorageCollection(collection.DictStorageCollection): @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.""" @@ -193,7 +194,7 @@ class TimerStorageCollection(collection.DictStorageCollection): # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) - return data + return data # type: ignore[no-any-return] class Timer(collection.CollectionEntity, RestoreEntity): @@ -231,24 +232,24 @@ class Timer(collection.CollectionEntity, RestoreEntity): return timer @property - def name(self): + def name(self) -> str | None: """Return name of the timer.""" return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @property - def state(self): + def state(self) -> str: """Return the current value of the timer.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = { + attrs: dict[str, Any] = { ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, } @@ -264,9 +265,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): @property def unique_id(self) -> str | None: """Return unique id for the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is about to be added to Home Assistant.""" # If we don't need to restore a previous state or no previous state exists, # start at idle @@ -302,7 +303,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_finish() @callback - def async_start(self, duration: timedelta | None = None): + def async_start(self, duration: timedelta | None = None) -> None: """Start a timer.""" if self._listener: self._listener() @@ -356,9 +357,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_pause(self): + def async_pause(self) -> None: """Pause a timer.""" - if self._listener is None: + if self._listener is None or self._end is None: return self._listener() @@ -370,7 +371,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_cancel(self): + def async_cancel(self) -> None: """Cancel a timer.""" if self._listener: self._listener() @@ -385,9 +386,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_finish(self): + def async_finish(self) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE: + if self._state != STATUS_ACTIVE or self._end is None: return if self._listener: @@ -405,9 +406,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def _async_finished(self, time): + def _async_finished(self, time: datetime) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE: + if self._state != STATUS_ACTIVE or self._end is None: return self._listener = None diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index c3f2c75e07b..d274960c211 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, TypeGuard import voluptuous as vol @@ -35,6 +35,8 @@ from .const import ( CONF_BEFORE_TIME, ) +SunEventType = Literal["sunrise", "sunset"] + _LOGGER = logging.getLogger(__name__) ATTR_AFTER = "after" @@ -60,7 +62,7 @@ async def async_setup_entry( ) -> None: """Initialize Times of the Day config entry.""" 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 after = cv.time(config_entry.options[CONF_AFTER_TIME]) @@ -83,7 +85,7 @@ async def async_setup_platform( ) -> None: """Set up the ToD sensors.""" 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 after = config[CONF_AFTER] @@ -97,7 +99,7 @@ async def async_setup_platform( async_add_entities([sensor]) -def _is_sun_event(sun_event): +def _is_sun_event(sun_event: time | SunEventType) -> TypeGuard[SunEventType]: """Return true if event is sun event not time.""" return sun_event in (SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) @@ -172,8 +174,8 @@ class TodSensor(BinarySensorEntity): # Calculate the today's event utc time or # if not available take next after_event_date = get_astral_event_date( - self.hass, str(self._after), nowutc - ) or get_astral_event_next(self.hass, str(self._after), nowutc) + self.hass, self._after, nowutc + ) or get_astral_event_next(self.hass, self._after, nowutc) else: # Convert local time provided to UTC today # datetime.combine(date, time, tzinfo) is not supported @@ -188,13 +190,13 @@ class TodSensor(BinarySensorEntity): # Calculate the today's event utc time or if not available take # next before_event_date = get_astral_event_date( - self.hass, str(self._before), nowutc - ) or get_astral_event_next(self.hass, str(self._before), nowutc) + self.hass, self._before, nowutc + ) or get_astral_event_next(self.hass, self._before, nowutc) # Before is earlier than after if before_event_date < after_event_date: # Take next day for before before_event_date = get_astral_event_next( - self.hass, str(self._before), after_event_date + self.hass, self._before, after_event_date ) else: # Convert local time provided to UTC today, see above @@ -248,7 +250,7 @@ class TodSensor(BinarySensorEntity): assert self._time_before is not None if _is_sun_event(self._after): self._time_after = get_astral_event_next( - self.hass, str(self._after), self._time_after - self._after_offset + self.hass, self._after, self._time_after - self._after_offset ) self._time_after += self._after_offset else: @@ -259,7 +261,7 @@ class TodSensor(BinarySensorEntity): if _is_sun_event(self._before): self._time_before = get_astral_event_next( - self.hass, str(self._before), self._time_before - self._before_offset + self.hass, self._before, self._time_before - self._before_offset ) self._time_before += self._before_offset else: @@ -274,7 +276,7 @@ class TodSensor(BinarySensorEntity): self._calculate_next_update() @callback - def _clean_up_listener(): + def _clean_up_listener() -> None: if self._unsub_update is not None: self._unsub_update() self._unsub_update = None diff --git a/homeassistant/components/todo/icons.json b/homeassistant/components/todo/icons.json new file mode 100644 index 00000000000..05c9af74630 --- /dev/null +++ b/homeassistant/components/todo/icons.json @@ -0,0 +1,14 @@ +{ + "entity_component": { + "_": { + "default": "mdi:clipboard-list" + } + }, + "services": { + "add_item": "mdi:clipboard-plus", + "get_items": "mdi:clipboard-arrow-down", + "remove_completed_items": "mdi:clipboard-remove", + "remove_item": "mdi:clipboard-minus", + "update_item": "mdi:clipboard-edit" + } +} diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 4cf62c6391d..2cce9da9c0f 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -22,7 +22,7 @@ class ListAddItemIntent(intent.IntentHandler): intent_type = INTENT_LIST_ADD_ITEM slot_schema = {"item": cv.string, "name": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" hass = intent_obj.hass diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 5ef7a5fe35b..717aa310ecd 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -93,7 +93,7 @@ }, "exceptions": { "item_not_found": { - "message": "Unable to find To-do item: {item}" + "message": "Unable to find to-do list item: {item}" }, "update_field_not_supported": { "message": "Entity does not support setting field: {service_field}" diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 5067e98642e..490e4ad9f1a 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -4,6 +4,8 @@ import asyncio import datetime from typing import Any, cast +from todoist_api_python.models import Task + from homeassistant.components.todo import ( TodoItem, TodoItemStatus, @@ -32,7 +34,7 @@ async def async_setup_entry( ) -def _task_api_data(item: TodoItem) -> dict[str, Any]: +def _task_api_data(item: TodoItem, api_data: Task | None = None) -> dict[str, Any]: """Convert a TodoItem to the set of add or update arguments.""" item_data: dict[str, Any] = { "content": item.summary, @@ -44,6 +46,12 @@ def _task_api_data(item: TodoItem) -> dict[str, Any]: item_data["due_datetime"] = due.isoformat() else: item_data["due_date"] = due.isoformat() + # In order to not lose any recurrence metadata for the task, we need to + # ensure that we send the `due_string` param if the task has it set. + # NOTE: It's ok to send stale data for non-recurring tasks. Any provided + # date/datetime will override this string. + if api_data and api_data.due: + item_data["due_string"] = api_data.due.string else: # Special flag "no date" clears the due date/datetime. # See https://developer.todoist.com/rest/v2/#update-a-task for more. @@ -126,7 +134,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if update_data := _task_api_data(item): + api_data = next((d for d in self.coordinator.data if d.id == uid), None) + if update_data := _task_api_data(item, api_data): await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: # Only update status if changed diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index f0cf94bb825..2fc41fac3af 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -63,7 +63,7 @@ class ToloSaunaData(NamedTuple): settings: SettingsInfo -class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): +class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylint: disable=hass-enforce-coordinator-module """DataUpdateCoordinator for TOLO Sauna.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -92,7 +92,7 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): return ToloSaunaData(status, settings) -class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): +class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """CoordinatorEntity for TOLO Sauna.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 74f2a5a6f55..033a4c5b51c 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -53,9 +53,12 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 25b814c106a..ea179219153 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -163,7 +163,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold Tomorrow.io data.""" def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 59174cff260..36f7ca12b84 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -118,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If Home Assistant is already in a running state, register the webhook # immediately, else trigger it after Home Assistant has finished starting. - if hass.state == CoreState.running: + if hass.state is CoreState.running: await coordinator.register_webhook() else: hass.bus.async_listen_once( diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index cc51bb03fec..16fbdbdd356 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -51,6 +51,7 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 4fb4daede65..41e6cd1c6bb 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -1,19 +1,30 @@ """Helpers for Toon.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging +from typing import Any, Concatenate, ParamSpec, TypeVar from toonapi import ToonConnectionError, ToonError +from .models import ToonEntity + +_ToonEntityT = TypeVar("_ToonEntityT", bound=ToonEntity) +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) -def toon_exception_handler(func): +def toon_exception_handler( + func: Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]], +) -> Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Toon calls to handle Toon exceptions. A decorator that wraps the passed in function, catches Toon errors, and handles the availability of the device in the data coordinator. """ - async def handler(self, *args, **kwargs): + async def handler(self: _ToonEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 967cbfa7e73..e10858c6c12 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -78,7 +78,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: client.locations[location_id].auto_bypass_low_battery = bypass -class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): +class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to fetch data from TotalConnect.""" config_entry: ConfigEntry diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index ed3d4500db1..5004646a667 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -69,6 +69,7 @@ class Touchline(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, touchline_thermostat): """Initialize the Touchline device.""" diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index f2a1e682304..e2342e617de 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -3,36 +3,66 @@ from __future__ import annotations import asyncio from datetime import timedelta +import logging from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from aiohttp import ClientSession +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, +) +from kasa.httpclient import get_cookie_jar from homeassistant import config_entries from homeassistant.components import network from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_ALIAS, + CONF_AUTHENTICATION, CONF_HOST, CONF_MAC, - CONF_NAME, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import ( + CONF_DEVICE_CONFIG, + CONNECT_TIMEOUT, + DISCOVERY_TIMEOUT, + DOMAIN, + PLATFORMS, +) from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + + +def create_async_tplink_clientsession(hass: HomeAssistant) -> ClientSession: + """Return aiohttp clientsession with cookie jar configured.""" + return async_create_clientsession( + hass, verify_ssl=False, cookie_jar=get_cookie_jar() + ) + @callback def async_trigger_discovery( @@ -46,17 +76,31 @@ def async_trigger_discovery( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_NAME: device.alias, + CONF_ALIAS: device.alias or mac_alias(device.mac), CONF_HOST: device.host, CONF_MAC: formatted_mac, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: """Discover TPLink devices on configured network interfaces.""" + + credentials = await get_credentials(hass) broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass) - tasks = [Discover.discover(target=str(address)) for address in broadcast_addresses] + tasks = [ + Discover.discover( + target=str(address), + discovery_timeout=DISCOVERY_TIMEOUT, + timeout=CONNECT_TIMEOUT, + credentials=credentials, + ) + for address in broadcast_addresses + ] discovered_devices: dict[str, SmartDevice] = {} for device_list in await asyncio.gather(*tasks): for device in device_list.values(): @@ -66,7 +110,7 @@ async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) if discovered_devices := await async_discover_devices(hass): async_trigger_discovery(hass, discovered_devices) @@ -85,12 +129,53 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" - host = entry.data[CONF_HOST] + host: str = entry.data[CONF_HOST] + credentials = await get_credentials(hass) + + config: DeviceConfig | None = None + if config_dict := entry.data.get(CONF_DEVICE_CONFIG): + try: + config = DeviceConfig.from_dict(config_dict) + except SmartDeviceException: + _LOGGER.warning( + "Invalid connection type dict for %s: %s", host, config_dict + ) + + if not config: + config = DeviceConfig(host) + else: + config.host = host + + config.timeout = CONNECT_TIMEOUT + if config.uses_http is True: + config.http_client = create_async_tplink_clientsession(hass) + if credentials: + config.credentials = credentials try: - device: SmartDevice = await Discover.discover_single(host, timeout=10) + device: SmartDevice = await SmartDevice.connect(config=config) + except AuthenticationException as ex: + raise ConfigEntryAuthFailed from ex except SmartDeviceException as ex: raise ConfigEntryNotReady from ex + device_config_dict = device.config.to_dict( + credentials_hash=device.credentials_hash, exclude_credentials=True + ) + updates: dict[str, Any] = {} + if device_config_dict != config_dict: + updates[CONF_DEVICE_CONFIG] = device_config_dict + if entry.data.get(CONF_ALIAS) != device.alias: + updates[CONF_ALIAS] = device.alias + if entry.data.get(CONF_MODEL) != device.model: + updates[CONF_MODEL] = device.model + if updates: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + **updates, + }, + ) found_mac = dr.format_mac(device.mac) if found_mac != entry.unique_id: # If the mac address of the device does not match the unique_id @@ -102,7 +187,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,10 +209,12 @@ 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() + return unload_ok @@ -126,3 +226,25 @@ def legacy_device_id(device: SmartDevice) -> str: if "_" not in device_id: return device_id return device_id.split("_")[1] + + +async def get_credentials(hass: HomeAssistant) -> Credentials | None: + """Retrieve the credentials from hass data.""" + if DOMAIN in hass.data and CONF_AUTHENTICATION in hass.data[DOMAIN]: + auth = hass.data[DOMAIN][CONF_AUTHENTICATION] + return Credentials(auth[CONF_USERNAME], auth[CONF_PASSWORD]) + + return None + + +async def set_credentials(hass: HomeAssistant, username: str, password: str) -> None: + """Save the credentials to HASS data.""" + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + } + + +def mac_alias(mac: str) -> str: + """Convert a MAC address to a short address for the UI.""" + return mac.replace(":", "")[-4:].upper() diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index a783c7b902f..e1e51f19e3a 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,28 +1,57 @@ """Config flow for TP-Link.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, + TimeoutException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from . import async_discover_devices -from .const import DOMAIN +from . import ( + async_discover_devices, + create_async_tplink_clientsession, + get_credentials, + mac_alias, + set_credentials, +) +from .const import CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DOMAIN + +STEP_AUTH_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 tplink.""" VERSION = 1 + MINOR_VERSION = 2 + reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -40,27 +69,117 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle integration discovery.""" return await self._async_handle_discovery( - discovery_info[CONF_HOST], discovery_info[CONF_MAC] + discovery_info[CONF_HOST], + discovery_info[CONF_MAC], + discovery_info[CONF_DEVICE_CONFIG], ) - async def _async_handle_discovery(self, host: str, mac: str) -> FlowResult: + @callback + def _update_config_if_entry_in_setup_error( + self, entry: ConfigEntry, host: str, config: dict + ) -> None: + """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config.""" + if entry.state not in ( + ConfigEntryState.SETUP_ERROR, + ConfigEntryState.SETUP_RETRY, + ): + return + entry_data = entry.data + entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) + if entry_config_dict == config and entry_data[CONF_HOST] == host: + return + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + raise AbortFlow("already_configured") + + async def _async_handle_discovery( + self, host: str, formatted_mac: str, config: dict | None = None + ) -> FlowResult: """Handle any discovery.""" - await self.async_set_unique_id(dr.format_mac(mac)) + current_entry = await self.async_set_unique_id( + formatted_mac, raise_on_progress=False + ) + if config and current_entry: + self._update_config_if_entry_in_setup_error(current_entry, host, config) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) self.context[CONF_HOST] = host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == host: return self.async_abort(reason="already_in_progress") - + credentials = await get_credentials(self.hass) try: - self._discovered_device = await self._async_try_connect( - host, raise_on_progress=True + await self._async_try_discover_and_update( + host, credentials, raise_on_progress=True ) + except AuthenticationException: + return await self.async_step_discovery_auth_confirm() except SmartDeviceException: return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() + async def async_step_discovery_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + assert self._discovered_device is not None + errors = {} + + credentials = await get_credentials(self.hass) + if credentials and credentials != self._discovered_device.config.credentials: + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + pass # Authentication exceptions should continue to the rest of the step + else: + self._discovered_device = device + return await self.async_step_discovery_confirm() + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + self._discovered_device = device + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self._async_create_entry_from_device(self._discovered_device) + + placeholders = self._async_make_placeholders_from_discovery() + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, + ) + + def _async_make_placeholders_from_discovery(self) -> dict[str, str]: + """Make placeholders for the discovery steps.""" + discovered_device = self._discovered_device + assert discovered_device is not None + return { + "name": discovered_device.alias or mac_alias(discovered_device.mac), + "model": discovered_device.model, + "host": discovered_device.host, + } + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,11 +189,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self._async_create_entry_from_device(self._discovered_device) self._set_confirm_only() - placeholders = { - "name": self._discovered_device.alias, - "model": self._discovered_device.model, - "host": self._discovered_device.host, - } + placeholders = self._async_make_placeholders_from_discovery() self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders @@ -88,8 +203,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() + self._async_abort_entries_match({CONF_HOST: host}) + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) try: - device = await self._async_try_connect(host, raise_on_progress=False) + device = await self._async_try_discover_and_update( + host, credentials, raise_on_progress=False + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() except SmartDeviceException: errors["base"] = "cannot_connect" else: @@ -101,6 +223,37 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_user_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + errors = {} + host = self.context[CONF_HOST] + assert self._discovered_device is not None + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={CONF_HOST: host}, + ) + async def async_step_pick_device( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -108,7 +261,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: mac = user_input[CONF_DEVICE] await self.async_set_unique_id(mac, raise_on_progress=False) - return self._async_create_entry_from_device(self._discovered_devices[mac]) + self._discovered_device = self._discovered_devices[mac] + host = self._discovered_device.host + + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) + + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() + except SmartDeviceException: + return self.async_abort(reason="cannot_connect") + return self._async_create_entry_from_device(device) configured_devices = { entry.unique_id for entry in self._async_current_entries() @@ -116,7 +283,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_devices = await async_discover_devices(self.hass) devices_name = { formatted_mac: ( - f"{device.alias} {device.model} ({device.host}) {formatted_mac}" + f"{device.alias or mac_alias(device.mac)} {device.model} ({device.host}) {formatted_mac}" ) for formatted_mac, device in self._discovered_devices.items() if formatted_mac not in configured_devices @@ -129,6 +296,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), ) + async def _async_reload_requires_auth_entries(self) -> None: + """Reload any in progress config flow that now have credentials.""" + _config_entries = self.hass.config_entries + + if reauth_entry := self.reauth_entry: + await _config_entries.async_reload(reauth_entry.entry_id) + + for flow in _config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ): + context: dict[str, Any] = flow["context"] + if context.get("source") != SOURCE_REAUTH: + continue + entry_id: str = context["entry_id"] + if entry := _config_entries.async_get_entry(entry_id): + await _config_entries.async_reload(entry.entry_id) + if entry.state is ConfigEntryState.LOADED: + _config_entries.flow.async_abort(flow["flow_id"]) + @callback def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: """Create a config entry from a smart device.""" @@ -137,16 +323,113 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=f"{device.alias} {device.model}", data={ CONF_HOST: device.host, + CONF_ALIAS: device.alias, + CONF_MODEL: device.model, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) + async def _async_try_discover_and_update( + self, + host: str, + credentials: Credentials | None, + raise_on_progress: bool, + ) -> SmartDevice: + """Try to discover the device and call update. + + Will try to connect to legacy devices if discovery fails. + """ + try: + self._discovered_device = await Discover.discover_single( + host, credentials=credentials + ) + except TimeoutException: + # Try connect() to legacy devices if discovery fails + self._discovered_device = await SmartDevice.connect( + config=DeviceConfig(host) + ) + else: + if self._discovered_device.config.uses_http: + self._discovered_device.config.http_client = ( + create_async_tplink_clientsession(self.hass) + ) + await self._discovered_device.update() + await self.async_set_unique_id( + dr.format_mac(self._discovered_device.mac), + raise_on_progress=raise_on_progress, + ) + return self._discovered_device + async def _async_try_connect( - self, host: str, raise_on_progress: bool = True + self, + discovered_device: SmartDevice, + credentials: Credentials | None, ) -> SmartDevice: """Try to connect.""" - self._async_abort_entries_match({CONF_HOST: host}) - device: SmartDevice = await Discover.discover_single(host) + self._async_abort_entries_match({CONF_HOST: discovered_device.host}) + + config = discovered_device.config + if credentials: + config.credentials = credentials + config.timeout = CONNECT_TIMEOUT + if config.uses_http: + config.http_client = create_async_tplink_clientsession(self.hass) + + self._discovered_device = await SmartDevice.connect(config=config) await self.async_set_unique_id( - dr.format_mac(device.mac), raise_on_progress=raise_on_progress + dr.format_mac(self._discovered_device.mac), + raise_on_progress=False, + ) + return self._discovered_device + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Start the reauthentication flow if the device needs updated credentials.""" + 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: dict[str, str] = {} + reauth_entry = self.reauth_entry + assert reauth_entry is not None + entry_data = reauth_entry.data + host = entry_data[CONF_HOST] + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + await self._async_try_discover_and_update( + host, + credentials=credentials, + raise_on_progress=True, + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self.async_abort(reason="reauth_successful") + + # Old config entries will not have these values. + alias = entry_data.get(CONF_ALIAS) or "unknown" + model = entry_data.get(CONF_MODEL) or "unknown" + + placeholders = {"name": alias, "model": model, "host": host} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, ) - return device diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 22b5741fceb..57047af8092 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -7,15 +7,14 @@ from homeassistant.const import Platform DOMAIN = "tplink" +DISCOVERY_TIMEOUT = 5 # Home Assistant will complain if startup takes > 10s +CONNECT_TIMEOUT = 5 + ATTR_CURRENT_A: Final = "current_a" ATTR_CURRENT_POWER_W: Final = "current_power_w" ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" -CONF_DIMMER: Final = "dimmer" -CONF_LIGHT: Final = "light" -CONF_STRIP: Final = "strip" -CONF_SWITCH: Final = "switch" -CONF_SENSOR: Final = "sensor" +CONF_DEVICE_CONFIG: Final = "device_config" PLATFORMS: Final = [Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 97c8397831d..798580ef3c2 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -4,9 +4,10 @@ from __future__ import annotations from datetime import timedelta import logging -from kasa import SmartDevice, SmartDeviceException +from kasa import AuthenticationException, SmartDevice, SmartDeviceException from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,11 +23,10 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): 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 +39,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ), ) - 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 AuthenticationException as ex: + raise ConfigEntryAuthFailed from ex 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 c81356ee658..c1b0cf12bfc 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN -from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData TO_REDACT = { # Entry fields @@ -29,6 +29,14 @@ TO_REDACT = { "longitude_i", # Cloud connectivity info "username", + # SMART devices + "device_id", + "hw_id", + "fw_id", + "oem_id", + "ssid", + "nickname", + "ip", } @@ -36,7 +44,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 2df9a856083..987ac455ae1 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -24,7 +24,7 @@ def async_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 @@ -40,7 +40,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): """Initialize the switch.""" super().__init__(coordinator) self.device: SmartDevice = device - self._attr_unique_id = self.device.device_id + self._attr_unique_id = device.device_id self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, identifiers={(DOMAIN, str(device.device_id))}, @@ -50,8 +50,3 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): sw_version=device.hw_info["sw_ver"], hw_version=device.hw_info["hw_ver"], ) - - @property - def is_on(self) -> bool: - """Return true if switch is on.""" - return bool(self.device.is_on) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 071e0506c58..87d30e4f76a 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -28,6 +29,7 @@ from . import legacy_device_id 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 +134,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 +152,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,16 +182,18 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): self._attr_unique_id = legacy_device_id(device) else: self._attr_unique_id = device.mac.replace(":", "").upper() - modes: set[ColorMode] = set() + modes: set[ColorMode] = {ColorMode.ONOFF} 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 + self._attr_supported_color_modes = filter_supported_color_modes(modes) + self._async_update_attrs() @callback def _async_extract_brightness_transition( @@ -269,34 +271,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): 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.""" - return cast(int, self.device.color_temp) - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return round((cast(int, self.device.brightness) * 255.0) / 100.0) - - @property - def hs_color(self) -> tuple[int, int] | None: - """Return the color.""" - hue, saturation, _ = self.device.hsv - return hue, saturation - - @property - def color_mode(self) -> ColorMode: + def _determine_color_mode(self) -> ColorMode: """Return the active color mode.""" if self.device.is_color: if self.device.is_variable_color_temp and self.device.color_temp: @@ -307,6 +282,27 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return ColorMode.BRIGHTNESS + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + device = self.device + self._attr_is_on = device.is_on + if device.is_dimmable: + self._attr_brightness = round((device.brightness * 255.0) / 100.0) + color_mode = self._determine_color_mode() + self._attr_color_mode = color_mode + if color_mode is ColorMode.COLOR_TEMP: + self._attr_color_temp_kelvin = device.color_temp + elif color_mode is ColorMode.HS: + hue, saturation, _ = device.hsv + self._attr_hs_color = hue, saturation + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + class TPLinkSmartLightStrip(TPLinkSmartBulb): """Representation of a TPLink Smart Light Strip.""" @@ -314,19 +310,19 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): device: SmartLightStrip _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - @property - def effect_list(self) -> list[str] | None: - """Return the list of available effects.""" - if effect_list := self.device.effect_list: - return cast(list[str], effect_list) - return None - - @property - def effect(self) -> str | None: - """Return the current effect.""" - if (effect := self.device.effect) and effect["enable"]: - return cast(str, effect["name"]) - return None + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + super()._async_update_attrs() + device = self.device + if (effect := device.effect) and effect["enable"]: + self._attr_effect = effect["name"] + else: + self._attr_effect = None + if effect_list := device.effect_list: + self._attr_effect_list = effect_list + else: + self._attr_effect_list = None @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 162344f04ec..a91e7e5a46f 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,13 +1,17 @@ { "domain": "tplink", - "name": "TP-Link Kasa Smart", - "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], + "name": "TP-Link Smart Home", + "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco", "@sdb9696"], "config_flow": true, "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, + { + "hostname": "e[sp]*", + "macaddress": "3C52A1*" + }, { "hostname": "e[sp]*", "macaddress": "54AF97*" @@ -32,6 +36,10 @@ "hostname": "hs*", "macaddress": "9C5322*" }, + { + "hostname": "k[lps]*", + "macaddress": "5091E3*" + }, { "hostname": "k[lps]*", "macaddress": "9C5322*" @@ -148,6 +156,10 @@ "hostname": "k[lps]*", "macaddress": "54AF97*" }, + { + "hostname": "l[59]*", + "macaddress": "54AF97*" + }, { "hostname": "k[lps]*", "macaddress": "AC15A2*" @@ -163,11 +175,99 @@ { "hostname": "k[lps]*", "macaddress": "1C61B4*" + }, + { + "hostname": "l5*", + "macaddress": "5CE931*" + }, + { + "hostname": "l[59]*", + "macaddress": "3C52A1*" + }, + { + "hostname": "l5*", + "macaddress": "5C628B*" + }, + { + "hostname": "tp*", + "macaddress": "5C628B*" + }, + { + "hostname": "p1*", + "macaddress": "482254*" + }, + { + "hostname": "s5*", + "macaddress": "482254*" + }, + { + "hostname": "p1*", + "macaddress": "30DE4B*" + }, + { + "hostname": "p1*", + "macaddress": "3C52A1*" + }, + { + "hostname": "tp*", + "macaddress": "3C52A1*" + }, + { + "hostname": "s5*", + "macaddress": "3C52A1*" + }, + { + "hostname": "l9*", + "macaddress": "A842A1*" + }, + { + "hostname": "l9*", + "macaddress": "3460F9*" + }, + { + "hostname": "hs*", + "macaddress": "704F57*" + }, + { + "hostname": "k[lps]*", + "macaddress": "74DA88*" + }, + { + "hostname": "p3*", + "macaddress": "788CB5*" + }, + { + "hostname": "p1*", + "macaddress": "CC32E5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "CC32E5*" + }, + { + "hostname": "hs*", + "macaddress": "CC32E5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "D80D17*" + }, + { + "hostname": "k[lps]*", + "macaddress": "D84732*" + }, + { + "hostname": "p1*", + "macaddress": "F0A731*" + }, + { + "hostname": "l9*", + "macaddress": "F0A731*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.4"] + "requirements": ["python-kasa[speedups]==0.6.2.1"] } diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py new file mode 100644 index 00000000000..4367f46711d --- /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 4fd957c2d8f..a3bb35840b2 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -33,6 +33,7 @@ from .const import ( ) from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity +from .models import TPLinkData @dataclass(frozen=True) @@ -106,31 +107,41 @@ def async_emeter_from_device( return None if device.is_bulb else 0.0 +def _async_sensors_for_device( + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + has_parent: bool = False, +) -> list[SmartPlugSensor]: + """Generate the sensors for the device.""" + return [ + SmartPlugSensor(device, coordinator, description, has_parent) + 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], True) + ) else: - entities.extend(_async_sensors_for_device(parent)) + entities.extend(_async_sensors_for_device(parent, parent_coordinator)) async_add_entities(entities) @@ -145,13 +156,20 @@ class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator, description: TPLinkSensorEntityDescription, + has_parent: bool = False, ) -> None: """Initialize the switch.""" super().__init__(device, coordinator) self.entity_description = description - self._attr_unique_id = ( - f"{legacy_device_id(self.device)}_{self.entity_description.key}" - ) + self._attr_unique_id = f"{legacy_device_id(device)}_{description.key}" + if has_parent: + assert device.alias + self._attr_translation_placeholders = {"device_name": device.alias} + if description.translation_key: + self._attr_translation_key = f"{description.translation_key}_child" + else: + assert description.device_class + self._attr_translation_key = f"{description.device_class.value}_child" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 3b4024c07b4..4aa4a3856bd 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -18,6 +18,34 @@ }, "discovery_confirm": { "description": "Do you want to set up {name} {model} ({host})?" + }, + "user_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "discovery_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below." + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -25,7 +53,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -38,6 +67,21 @@ }, "today_consumption": { "name": "Today's consumption" + }, + "current_consumption_child": { + "name": "{device_name} current consumption" + }, + "total_consumption_child": { + "name": "{device_name} total consumption" + }, + "today_consumption_child": { + "name": "{device_name} today's consumption" + }, + "current_child": { + "name": "{device_name} current" + }, + "voltage_child": { + "name": "{device_name} voltage" } }, "switch": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index fb812abc293..3e81870d80f 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -9,13 +9,14 @@ from kasa import SmartDevice, SmartPlug from homeassistant.components.switch import SwitchEntity 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 legacy_device_id 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,13 @@ 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)) + # this will be removed on the led is implemented + if hasattr(device, "led"): + entities.append(SmartPlugLedSwitch(device, parent_coordinator)) async_add_entities(entities) @@ -57,13 +61,8 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): ) -> None: """Initialize the LED switch.""" super().__init__(device, coordinator) - self._attr_unique_id = f"{self.device.mac}_led" - - @property - def icon(self) -> str: - """Return the icon for the LED.""" - return "mdi:led-on" if self.is_on else "mdi:led-off" + self._async_update_attrs() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -75,16 +74,24 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Turn the LED switch off.""" await self.device.set_led(False) - @property - def is_on(self) -> bool: - """Return true if LED switch is on.""" - return bool(self.device.led) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + is_on = self.device.led + self._attr_is_on = is_on + self._attr_icon = "mdi:led-on" if is_on else "mdi:led-off" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - _attr_name = None + _attr_name: str | None = None def __init__( self, @@ -95,6 +102,7 @@ class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): super().__init__(device, coordinator) # For backwards compat with pyHS100 self._attr_unique_id = legacy_device_id(device) + self._async_update_attrs() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -106,6 +114,17 @@ class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Turn the switch off.""" await self.device.turn_off() + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self.device.is_on + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + class SmartPlugSwitchChild(SmartPlugSwitch): """Representation of an individual plug of a TPLink Smart Plug strip.""" @@ -117,8 +136,8 @@ class SmartPlugSwitchChild(SmartPlugSwitch): plug: SmartDevice, ) -> None: """Initialize the child switch.""" - super().__init__(device, coordinator) self._plug = plug + super().__init__(device, coordinator) self._attr_unique_id = legacy_device_id(plug) self._attr_name = plug.alias @@ -132,7 +151,7 @@ class SmartPlugSwitchChild(SmartPlugSwitch): """Turn the child switch off.""" await self._plug.turn_off() - @property - def is_on(self) -> bool: - """Return true if child switch is on.""" - return bool(self._plug.is_on) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self._plug.is_on diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 5ac3085520e..d64dc003576 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -1,6 +1,9 @@ """Support for TP-Link LTE modems.""" +from __future__ import annotations + import asyncio import logging +from typing import Any import aiohttp import attr @@ -15,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType @@ -59,20 +62,20 @@ CONFIG_SCHEMA = vol.Schema( class ModemData: """Class for modem state.""" - host = attr.ib() - modem = attr.ib() + host: str = attr.ib() + modem: tp_connected.Modem = attr.ib() - connected = attr.ib(init=False, default=True) + connected: bool = attr.ib(init=False, default=True) @attr.s class LTEData: """Shared state.""" - websession = attr.ib() + websession: aiohttp.ClientSession = attr.ib() modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) - def get_modem_data(self, config): + def get_modem_data(self, config: dict[str, Any]) -> ModemData | None: """Get the requested or the only modem_data value.""" if CONF_HOST in config: return self.modem_data.get(config[CONF_HOST]) @@ -107,14 +110,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _setup_lte(hass, lte_config, delay=0): +async def _setup_lte( + hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0 +) -> None: """Set up a TP-Link LTE modem.""" - host = lte_config[CONF_HOST] - password = lte_config[CONF_PASSWORD] + host: str = lte_config[CONF_HOST] + password: str = lte_config[CONF_PASSWORD] - websession = hass.data[DATA_KEY].websession - modem = tp_connected.Modem(hostname=host, websession=websession) + lte_data: LTEData = hass.data[DATA_KEY] + modem = tp_connected.Modem(hostname=host, websession=lte_data.websession) modem_data = ModemData(host, modem) @@ -124,7 +129,7 @@ async def _setup_lte(hass, lte_config, delay=0): retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) @callback - def cleanup_retry(event): + def cleanup_retry(event: Event) -> None: """Clean up retry task resources.""" if not retry_task.done(): retry_task.cancel() @@ -132,20 +137,23 @@ async def _setup_lte(hass, lte_config, delay=0): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) -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) modem_data.connected = True - hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + lte_data: LTEData = hass.data[DATA_KEY] + lte_data.modem_data[modem_data.host] = modem_data - async def cleanup(event): + async def cleanup(event: Event) -> None: """Clean up resources.""" await modem_data.modem.logout() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) -async def _retry_login(hass, modem_data, password): +async def _retry_login( + hass: HomeAssistant, modem_data: ModemData, password: str +) -> None: """Sleep and retry setup.""" _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py index 890a4ff6c88..eb742a5e4e9 100644 --- a/homeassistant/components/tplink_lte/notify.py +++ b/homeassistant/components/tplink_lte/notify.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import attr import tp_connected @@ -11,7 +12,7 @@ from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_KEY +from . import DATA_KEY, LTEData _LOGGER = logging.getLogger(__name__) @@ -31,13 +32,14 @@ async def async_get_service( class TplinkNotifyService(BaseNotificationService): """Implementation of a notification service.""" - hass = attr.ib() - config = attr.ib() + hass: HomeAssistant = attr.ib() + config: dict[str, Any] = attr.ib() - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" - modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + lte_data: LTEData = self.hass.data[DATA_KEY] + modem_data = lte_data.get_modem_data(self.config) if not modem_data: _LOGGER.error("No modem available") return diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index f6a75abe6d8..3f27417894d 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -61,7 +61,9 @@ async def create_omada_client( is not None ): # TP-Link API uses cookies for login session, so an unsafe cookie jar is required for IP addresses - websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + websession = async_create_clientsession( + hass, cookie_jar=CookieJar(unsafe=True), verify_ssl=verify_ssl + ) else: websession = async_get_clientsession(hass, verify_ssl=verify_ssl) diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 194f18ae9bf..be9e875037e 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -15,7 +15,7 @@ POLL_SWITCH_PORT = 300 POLL_GATEWAY = 300 -class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about ports on a switch.""" def __init__( @@ -36,7 +36,7 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): return {p.port_id: p for p in ports} -class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): +class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about the site's gateway.""" def __init__( diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 1e653a53aae..a5f54071c4f 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -34,7 +34,7 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about ports on a switch.""" def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: diff --git a/homeassistant/components/tplink_tapo/__init__.py b/homeassistant/components/tplink_tapo/__init__.py new file mode 100644 index 00000000000..d76870ccea4 --- /dev/null +++ b/homeassistant/components/tplink_tapo/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: TP-Link Tapo.""" diff --git a/homeassistant/components/tplink_tapo/manifest.json b/homeassistant/components/tplink_tapo/manifest.json new file mode 100644 index 00000000000..a0d86b2dc62 --- /dev/null +++ b/homeassistant/components/tplink_tapo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "tplink_tapo", + "name": "Tapo", + "integration_type": "virtual", + "supported_by": "tplink" +} diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 5dffd629e80..492f609907e 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,4 +1,4 @@ -"""Support for Traccar.""" +"""Support for Traccar Client.""" from http import HTTPStatus from aiohttp import web @@ -56,7 +56,7 @@ WEBHOOK_SCHEMA = vol.Schema( async def handle_webhook(hass, webhook_id, request): - """Handle incoming webhook with Traccar request.""" + """Handle incoming webhook with Traccar Client request.""" try: data = WEBHOOK_SCHEMA(dict(request.query)) except vol.MultipleInvalid as error: diff --git a/homeassistant/components/traccar/config_flow.py b/homeassistant/components/traccar/config_flow.py index 3702316ffb9..3d62d0a842d 100644 --- a/homeassistant/components/traccar/config_flow.py +++ b/homeassistant/components/traccar/config_flow.py @@ -1,10 +1,10 @@ -"""Config flow for Traccar.""" +"""Config flow for Traccar Client.""" from homeassistant.helpers import config_entry_flow from .const import DOMAIN config_entry_flow.register_webhook_flow( DOMAIN, - "Traccar Webhook", + "Traccar Client Webhook", {"docs_url": "https://www.home-assistant.io/integrations/traccar/"}, ) diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index 06dd368b6a3..df4bfa8ec99 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -1,4 +1,4 @@ -"""Constants for Traccar integration.""" +"""Constants for Traccar client integration.""" DOMAIN = "traccar" diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 3406997fd98..dbcb30e3a23 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -1,30 +1,25 @@ """Support for Traccar device tracking.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging +from typing import Any -from pytraccar import ( - ApiClient, - DeviceModel, - GeofenceModel, - PositionModel, - TraccarAuthenticationException, - TraccarConnectionException, - TraccarException, -) -from stringcase import camelcase +from pytraccar import ApiClient, TraccarException import voluptuous as vol from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, AsyncSeeCallback, SourceType, TrackerEntity, ) -from homeassistant.config_entries import ConfigEntry +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_EVENT, CONF_HOST, @@ -34,34 +29,34 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STARTED, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + Event, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import slugify from . import DOMAIN, TRACKER_UPDATE from .const import ( ATTR_ACCURACY, - ATTR_ADDRESS, ATTR_ALTITUDE, ATTR_BATTERY, ATTR_BEARING, - ATTR_CATEGORY, - ATTR_GEOFENCE, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_MOTION, ATTR_SPEED, - ATTR_STATUS, - ATTR_TRACCAR_ID, - ATTR_TRACKER, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_ON, EVENT_ALARM, @@ -178,7 +173,7 @@ async def async_setup_scanner( async_see: AsyncSeeCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: - """Validate the configuration and return a Traccar scanner.""" + """Import configuration to the new integration.""" api = ApiClient( host=config[CONF_HOST], port=config[CONF_PORT], @@ -188,180 +183,62 @@ async def async_setup_scanner( client_session=async_get_clientsession(hass, config[CONF_VERIFY_SSL]), ) - scanner = TraccarScanner( - api, - hass, - async_see, - config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), - config[CONF_MAX_ACCURACY], - config[CONF_SKIP_ACCURACY_ON], - config[CONF_MONITORED_CONDITIONS], - config[CONF_EVENT], - ) - - return await scanner.async_init() - - -class TraccarScanner: - """Define an object to retrieve Traccar data.""" - - def __init__( - self, - api: ApiClient, - hass: HomeAssistant, - async_see: AsyncSeeCallback, - scan_interval: timedelta, - max_accuracy: int, - skip_accuracy_on: bool, - custom_attributes: list[str], - event_types: list[str], - ) -> None: - """Initialize.""" - - if EVENT_ALL_EVENTS in event_types: - event_types = EVENTS - self._event_types = {camelcase(evt): evt for evt in event_types} - self._custom_attributes = custom_attributes - self._scan_interval = scan_interval - self._async_see = async_see - self._api = api - self._hass = hass - self._max_accuracy = max_accuracy - self._skip_accuracy_on = skip_accuracy_on - self._devices: list[DeviceModel] = [] - self._positions: list[PositionModel] = [] - self._geofences: list[GeofenceModel] = [] - - async def async_init(self): - """Further initialize connection to Traccar.""" + async def _run_import(_: Event): + known_devices: dict[str, dict[str, Any]] = {} try: - await self._api.get_server() - except TraccarAuthenticationException: - _LOGGER.error("Authentication for Traccar failed") - return False - except TraccarConnectionException as exception: - _LOGGER.error("Connection with Traccar failed - %s", exception) - return False + known_devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) - await self._async_update() - async_track_time_interval( - self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + if known_devices: + traccar_devices: list[str] = [] + try: + resp = await api.get_devices() + traccar_devices = [slugify(device["name"]) for device in resp] + except TraccarException as exception: + _LOGGER.error("Error while getting device data: %s", exception) + return + + for dev_name in traccar_devices: + if dev_name in known_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}") + + hass.async_create_task( + hass.config_entries.flow.async_init( + "traccar_server", + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - return True - async def _async_update(self, now=None): - """Update info from Traccar.""" - _LOGGER.debug("Updating device data") - try: - ( - self._devices, - self._positions, - self._geofences, - ) = await asyncio.gather( - self._api.get_devices(), - self._api.get_positions(), - self._api.get_geofences(), - ) - except TraccarException as ex: - _LOGGER.error("Error while updating device data: %s", ex) - return - - self._hass.async_create_task(self.import_device_data()) - if self._event_types: - self._hass.async_create_task(self.import_events()) - - async def import_device_data(self): - """Import device data from Traccar.""" - for position in self._positions: - device = next( - (dev for dev in self._devices if dev["id"] == position["deviceId"]), - None, - ) - - if not device: - continue - - attr = { - ATTR_TRACKER: "traccar", - ATTR_ADDRESS: position["address"], - ATTR_SPEED: position["speed"], - ATTR_ALTITUDE: position["altitude"], - ATTR_MOTION: position["attributes"].get("motion", False), - ATTR_TRACCAR_ID: device["id"], - ATTR_GEOFENCE: next( - ( - geofence["name"] - for geofence in self._geofences - if geofence["id"] in (position["geofenceIds"] or []) - ), - None, - ), - ATTR_CATEGORY: device["category"], - ATTR_STATUS: device["status"], - } - - skip_accuracy_filter = False - - for custom_attr in self._custom_attributes: - if device["attributes"].get(custom_attr) is not None: - attr[custom_attr] = position["attributes"][custom_attr] - if custom_attr in self._skip_accuracy_on: - skip_accuracy_filter = True - if position["attributes"].get(custom_attr) is not None: - attr[custom_attr] = position["attributes"][custom_attr] - if custom_attr in self._skip_accuracy_on: - skip_accuracy_filter = True - - accuracy = position["accuracy"] or 0.0 - if ( - not skip_accuracy_filter - and self._max_accuracy > 0 - and accuracy > self._max_accuracy - ): - _LOGGER.debug( - "Excluded position by accuracy filter: %f (%s)", - accuracy, - attr[ATTR_TRACCAR_ID], - ) - continue - - await self._async_see( - dev_id=slugify(device["name"]), - gps=(position["latitude"], position["longitude"]), - gps_accuracy=accuracy, - battery=position["attributes"].get("batteryLevel", -1), - attributes=attr, - ) - - async def import_events(self): - """Import events from Traccar.""" - # get_reports_events requires naive UTC datetimes as of 1.0.0 - start_intervel = dt_util.utcnow().replace(tzinfo=None) - events = await self._api.get_reports_events( - devices=[device["id"] for device in self._devices], - start_time=start_intervel, - end_time=start_intervel - self._scan_interval, - event_types=self._event_types.keys(), + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Traccar", + }, ) - if events is not None: - for event in events: - self._hass.bus.async_fire( - f"traccar_{self._event_types.get(event['type'])}", - { - "device_traccar_id": event["deviceId"], - "device_name": next( - ( - dev["name"] - for dev in self._devices - if dev["id"] == event["deviceId"] - ), - None, - ), - "type": event["type"], - "serverTime": event["eventTime"], - "attributes": event["attributes"], - }, - ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) + return True class TraccarEntity(TrackerEntity, RestoreEntity): diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 403ba3987ab..c3b9e540ab6 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -1,11 +1,11 @@ { "domain": "traccar", - "name": "Traccar", + "name": "Traccar Client", "codeowners": ["@ludeeus"], "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/traccar", - "iot_class": "local_polling", + "iot_class": "cloud_push", "loggers": ["pytraccar"], "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] } diff --git a/homeassistant/components/traccar/strings.json b/homeassistant/components/traccar/strings.json index 62bcf608852..804a26e3b0a 100644 --- a/homeassistant/components/traccar/strings.json +++ b/homeassistant/components/traccar/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up Traccar", - "description": "Are you sure you want to set up Traccar?" + "title": "Set up Traccar Client", + "description": "Are you sure you want to set up Traccar Client?" } }, "abort": { @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up the webhook feature in Traccar.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to set up the webhook feature in Traccar Client.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." } } } diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py new file mode 100644 index 00000000000..53770757c81 --- /dev/null +++ b/homeassistant/components/traccar_server/__init__.py @@ -0,0 +1,70 @@ +"""The Traccar Server integration.""" +from __future__ import annotations + +from pytraccar import ApiClient + +from homeassistant.config_entries import ConfigEntry +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.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, +) +from .coordinator import TraccarServerCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Traccar Server from a config entry.""" + coordinator = TraccarServerCoordinator( + hass=hass, + client=ApiClient( + client_session=async_get_clientsession(hass), + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ssl=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ), + events=entry.options.get(CONF_EVENTS, []), + max_accuracy=entry.options.get(CONF_MAX_ACCURACY, 0.0), + skip_accuracy_filter_for=entry.options.get(CONF_SKIP_ACCURACY_FILTER_FOR, []), + custom_attributes=entry.options.get(CONF_CUSTOM_ATTRIBUTES, []), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][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_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py new file mode 100644 index 00000000000..a2a7daaaa98 --- /dev/null +++ b/homeassistant/components/traccar_server/config_flow.py @@ -0,0 +1,202 @@ +"""Config flow for Traccar Server integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pytraccar import ApiClient, ServerModel, TraccarException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, + EVENTS, + LOGGER, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_PORT, default="8082"): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL, default=False): BooleanSelector(BooleanSelectorConfig()), + vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector( + BooleanSelectorConfig() + ), + } +) + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=vol.Schema( + { + vol.Optional(CONF_MAX_ACCURACY, default=0.0): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=0.0, + ) + ), + vol.Optional(CONF_CUSTOM_ATTRIBUTES, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=[], + ) + ), + vol.Optional(CONF_SKIP_ACCURACY_FILTER_FOR, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=[], + ) + ), + vol.Optional(CONF_EVENTS, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=list(EVENTS), + ) + ), + } + ) + ), +} + + +class TraccarServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Traccar Server.""" + + async def _get_server_info(self, user_input: dict[str, Any]) -> ServerModel: + """Get server info.""" + client = ApiClient( + client_session=async_get_clientsession(self.hass), + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ssl=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + return await client.get_server() + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + try: + await self._get_server_info(user_input) + except TraccarException as exception: + LOGGER.error("Unable to connect to Traccar Server: %s", exception) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + 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, import_info: Mapping[str, Any]) -> FlowResult: + """Import an entry.""" + configured_port = str(import_info[CONF_PORT]) + self._async_abort_entries_match( + { + CONF_HOST: import_info[CONF_HOST], + CONF_PORT: configured_port, + } + ) + if "all_events" in (imported_events := import_info.get("event", [])): + events = list(EVENTS.values()) + else: + events = imported_events + return self.async_create_entry( + title=f"{import_info[CONF_HOST]}:{configured_port}", + data={ + CONF_HOST: import_info[CONF_HOST], + CONF_PORT: configured_port, + CONF_SSL: import_info.get(CONF_SSL, False), + CONF_VERIFY_SSL: import_info.get(CONF_VERIFY_SSL, True), + CONF_USERNAME: import_info[CONF_USERNAME], + CONF_PASSWORD: import_info[CONF_PASSWORD], + }, + options={ + CONF_MAX_ACCURACY: import_info[CONF_MAX_ACCURACY], + CONF_EVENTS: events, + CONF_CUSTOM_ATTRIBUTES: import_info.get("monitored_conditions", []), + CONF_SKIP_ACCURACY_FILTER_FOR: import_info.get( + "skip_accuracy_filter_on", [] + ), + }, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> SchemaOptionsFlowHandler: + """Get the options flow for this handler.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/traccar_server/const.py b/homeassistant/components/traccar_server/const.py new file mode 100644 index 00000000000..ca95e706d61 --- /dev/null +++ b/homeassistant/components/traccar_server/const.py @@ -0,0 +1,39 @@ +"""Constants for the Traccar Server integration.""" +from logging import getLogger + +DOMAIN = "traccar_server" +LOGGER = getLogger(__package__) + +ATTR_ADDRESS = "address" +ATTR_ALTITUDE = "altitude" +ATTR_CATEGORY = "category" +ATTR_GEOFENCE = "geofence" +ATTR_MOTION = "motion" +ATTR_SPEED = "speed" +ATTR_STATUS = "status" +ATTR_TRACKER = "tracker" +ATTR_TRACCAR_ID = "traccar_id" + +CONF_MAX_ACCURACY = "max_accuracy" +CONF_CUSTOM_ATTRIBUTES = "custom_attributes" +CONF_EVENTS = "events" +CONF_SKIP_ACCURACY_FILTER_FOR = "skip_accuracy_filter_for" + +EVENTS = { + "deviceMoving": "device_moving", + "commandResult": "command_result", + "deviceFuelDrop": "device_fuel_drop", + "geofenceEnter": "geofence_enter", + "deviceOffline": "device_offline", + "driverChanged": "driver_changed", + "geofenceExit": "geofence_exit", + "deviceOverspeed": "device_overspeed", + "deviceOnline": "device_online", + "deviceStopped": "device_stopped", + "maintenance": "maintenance", + "alarm": "alarm", + "textMessage": "text_message", + "deviceUnknown": "device_unknown", + "ignitionOff": "ignition_off", + "ignitionOn": "ignition_on", +} diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py new file mode 100644 index 00000000000..df9b5adaf1a --- /dev/null +++ b/homeassistant/components/traccar_server/coordinator.py @@ -0,0 +1,167 @@ +"""Data update coordinator for Traccar Server.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, TypedDict + +from pytraccar import ( + ApiClient, + DeviceModel, + GeofenceModel, + PositionModel, + TraccarException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, EVENTS, LOGGER +from .helpers import get_device, get_first_geofence + + +class TraccarServerCoordinatorDataDevice(TypedDict): + """Traccar Server coordinator data.""" + + device: DeviceModel + geofence: GeofenceModel | None + position: PositionModel + attributes: dict[str, Any] + + +TraccarServerCoordinatorData = dict[str, TraccarServerCoordinatorDataDevice] + + +class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]): + """Class to manage fetching Traccar Server data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: ApiClient, + *, + events: list[str], + max_accuracy: float, + skip_accuracy_filter_for: list[str], + custom_attributes: list[str], + ) -> None: + """Initialize global Traccar Server data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = client + self.custom_attributes = custom_attributes + self.events = events + self.max_accuracy = max_accuracy + self.skip_accuracy_filter_for = skip_accuracy_filter_for + self._last_event_import: datetime | None = None + + async def _async_update_data(self) -> TraccarServerCoordinatorData: + """Fetch data from Traccar Server.""" + LOGGER.debug("Updating device data") + data: TraccarServerCoordinatorData = {} + try: + ( + devices, + positions, + geofences, + ) = await asyncio.gather( + self.client.get_devices(), + self.client.get_positions(), + self.client.get_geofences(), + ) + except TraccarException as ex: + raise UpdateFailed(f"Error while updating device data: {ex}") from ex + + if TYPE_CHECKING: + assert isinstance(devices, list[DeviceModel]) # type: ignore[misc] + assert isinstance(positions, list[PositionModel]) # type: ignore[misc] + assert isinstance(geofences, list[GeofenceModel]) # type: ignore[misc] + + for position in positions: + if (device := get_device(position["deviceId"], devices)) is None: + continue + + attr = {} + skip_accuracy_filter = False + + for custom_attr in self.custom_attributes: + attr[custom_attr] = device["attributes"].get( + custom_attr, + position["attributes"].get(custom_attr, None), + ) + if custom_attr in self.skip_accuracy_filter_for: + skip_accuracy_filter = True + + accuracy = position["accuracy"] or 0.0 + if ( + not skip_accuracy_filter + and self.max_accuracy > 0 + and accuracy > self.max_accuracy + ): + LOGGER.debug( + "Excluded position by accuracy filter: %f (%s)", + accuracy, + device["id"], + ) + continue + + data[device["uniqueId"]] = { + "device": device, + "geofence": get_first_geofence( + geofences, + position["geofenceIds"] or [], + ), + "position": position, + "attributes": attr, + } + + if self.events: + self.hass.async_create_task(self.import_events(devices)) + + return data + + async def import_events(self, devices: list[DeviceModel]) -> None: + """Import events from Traccar.""" + start_time = dt_util.utcnow().replace(tzinfo=None) + end_time = None + + if self._last_event_import is not None: + end_time = start_time - (start_time - self._last_event_import) + + events = await self.client.get_reports_events( + devices=[device["id"] for device in devices], + start_time=start_time, + end_time=end_time, + event_types=self.events, + ) + if not events: + return + + self._last_event_import = start_time + for event in events: + device = get_device(event["deviceId"], devices) + self.hass.bus.async_fire( + # This goes against two of the HA core guidelines: + # 1. Event names should be prefixed with the domain name of + # the integration + # 2. This should be event entities + # + # However, to not break it for those who currently use + # the "old" integration, this is kept as is. + f"traccar_{EVENTS[event['type']]}", + { + "device_traccar_id": event["deviceId"], + "device_name": device["name"] if device else None, + "type": event["type"], + "serverTime": event["eventTime"], + "attributes": event["attributes"], + }, + ) diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py new file mode 100644 index 00000000000..226d942e465 --- /dev/null +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -0,0 +1,86 @@ +"""Support for Traccar server device tracking.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_ADDRESS, + ATTR_ALTITUDE, + ATTR_CATEGORY, + ATTR_GEOFENCE, + ATTR_MOTION, + ATTR_SPEED, + ATTR_STATUS, + ATTR_TRACCAR_ID, + ATTR_TRACKER, + DOMAIN, +) +from .coordinator import TraccarServerCoordinator +from .entity import TraccarServerEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up device tracker entities.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TraccarServerDeviceTracker(coordinator, entry["device"]) + for entry in coordinator.data.values() + ) + + +class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): + """Represent a tracked device.""" + + _attr_has_entity_name = True + _attr_name = None + + @property + def battery_level(self) -> int: + """Return battery value of the device.""" + return self.traccar_position["attributes"].get("batteryLevel", -1) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + geofence_name = self.traccar_geofence["name"] if self.traccar_geofence else None + return { + **self.traccar_attributes, + ATTR_ADDRESS: self.traccar_position["address"], + ATTR_ALTITUDE: self.traccar_position["altitude"], + ATTR_CATEGORY: self.traccar_device["category"], + ATTR_GEOFENCE: geofence_name, + ATTR_MOTION: self.traccar_position["attributes"].get("motion", False), + ATTR_SPEED: self.traccar_position["speed"], + ATTR_STATUS: self.traccar_device["status"], + ATTR_TRACCAR_ID: self.traccar_device["id"], + ATTR_TRACKER: DOMAIN, + } + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return self.traccar_position["latitude"] + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return self.traccar_position["longitude"] + + @property + def location_accuracy(self) -> int: + """Return the gps accuracy of the device.""" + return self.traccar_position["accuracy"] + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.GPS diff --git a/homeassistant/components/traccar_server/entity.py b/homeassistant/components/traccar_server/entity.py new file mode 100644 index 00000000000..d44c78cafae --- /dev/null +++ b/homeassistant/components/traccar_server/entity.py @@ -0,0 +1,59 @@ +"""Base entity for Traccar Server.""" +from __future__ import annotations + +from typing import Any + +from pytraccar import DeviceModel, GeofenceModel, PositionModel + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TraccarServerCoordinator + + +class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]): + """Base entity for Traccar Server.""" + + def __init__( + self, + coordinator: TraccarServerCoordinator, + device: DeviceModel, + ) -> None: + """Initialize the Traccar Server entity.""" + super().__init__(coordinator) + self.device_id = device["uniqueId"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device["uniqueId"])}, + model=device["model"], + name=device["name"], + ) + self._attr_unique_id = device["uniqueId"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.coordinator.last_update_success + and self.device_id in self.coordinator.data + ) + + @property + def traccar_device(self) -> DeviceModel: + """Return the device.""" + return self.coordinator.data[self.device_id]["device"] + + @property + def traccar_geofence(self) -> GeofenceModel | None: + """Return the geofence.""" + return self.coordinator.data[self.device_id]["geofence"] + + @property + def traccar_position(self) -> PositionModel: + """Return the position.""" + return self.coordinator.data[self.device_id]["position"] + + @property + def traccar_attributes(self) -> dict[str, Any]: + """Return the attributes.""" + return self.coordinator.data[self.device_id]["attributes"] diff --git a/homeassistant/components/traccar_server/helpers.py b/homeassistant/components/traccar_server/helpers.py new file mode 100644 index 00000000000..ee812c35b8b --- /dev/null +++ b/homeassistant/components/traccar_server/helpers.py @@ -0,0 +1,23 @@ +"""Helper functions for the Traccar Server integration.""" +from __future__ import annotations + +from pytraccar import DeviceModel, GeofenceModel + + +def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None: + """Return the device.""" + return next( + (dev for dev in devices if dev["id"] == device_id), + None, + ) + + +def get_first_geofence( + geofences: list[GeofenceModel], + target: list[int], +) -> GeofenceModel | None: + """Return the geofence.""" + return next( + (geofence for geofence in geofences if geofence["id"] in target), + None, + ) diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json new file mode 100644 index 00000000000..ca284dd02dd --- /dev/null +++ b/homeassistant/components/traccar_server/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "traccar_server", + "name": "Traccar Server", + "codeowners": ["@ludeeus"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/traccar_server", + "iot_class": "local_polling", + "requirements": ["pytraccar==2.0.0"] +} diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json new file mode 100644 index 00000000000..87da7e8cdd1 --- /dev/null +++ b/homeassistant/components/traccar_server/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your Traccar Server", + "username": "The username (email) you use to login to your Traccar Server" + } + } + }, + "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%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "max_accuracy": "Max accuracy", + "skip_accuracy_filter_for": "Position skip filter for attributes", + "custom_attributes": "Custom attributes", + "events": "Events" + }, + "data_description": { + "max_accuracy": "Any position reports with accuracy higher than this value will be ignored", + "skip_accuracy_filter_for": "Attributes defined here will bypass the accuracy filter if they are present in the update", + "custom_attributes": "Add any custom or calculated attributes here. These will be added to the device attributes", + "events": "Selected events will be fired in Home Assistant" + } + } + } + } +} diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 84619b7a983..43e591bc6e1 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -44,7 +44,7 @@ TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] @callback def _get_data(hass: HomeAssistant) -> TraceData: - return hass.data[DATA_TRACE] + return hass.data[DATA_TRACE] # type: ignore[no-any-return] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index 9530554449e..2fe37412dfb 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -163,8 +163,8 @@ class RestoredTrace(BaseTrace): def as_extended_dict(self) -> dict[str, Any]: """Return an extended dictionary version of this RestoredTrace.""" - return self._dict + return self._dict # type: ignore[no-any-return] def as_short_dict(self) -> dict[str, Any]: """Return a brief dictionary version of this RestoredTrace.""" - return self._short_dict + return self._short_dict # type: ignore[no-any-return] diff --git a/homeassistant/components/trace/websocket_api.py b/homeassistant/components/trace/websocket_api.py index bf5ebfc1031..6a5280aacf7 100644 --- a/homeassistant/components/trace/websocket_api.py +++ b/homeassistant/components/trace/websocket_api.py @@ -142,8 +142,8 @@ def websocket_breakpoint_set( ) -> None: """Set breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" - node = msg["node"] - run_id = msg.get("run_id") + node: str = msg["node"] + run_id: str | None = msg.get("run_id") if ( SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) @@ -173,8 +173,8 @@ def websocket_breakpoint_clear( ) -> None: """Clear breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" - node = msg["node"] - run_id = msg.get("run_id") + node: str = msg["node"] + run_id: str | None = msg.get("run_id") result = breakpoint_clear(hass, key, run_id, node) @@ -211,7 +211,7 @@ def websocket_subscribe_breakpoint_events( """Subscribe to breakpoint events.""" @callback - def breakpoint_hit(key, run_id, node): + def breakpoint_hit(key: str, run_id: str, node: str) -> None: """Forward events to websocket.""" domain, item_id = key.split(".", 1) connection.send_message( @@ -231,7 +231,7 @@ def websocket_subscribe_breakpoint_events( ) @callback - def unsub(): + def unsub() -> None: """Unsubscribe from breakpoint events.""" remove_signal() if ( @@ -263,7 +263,7 @@ def websocket_debug_continue( ) -> None: """Resume execution of halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_continue(hass, key, run_id) @@ -287,7 +287,7 @@ def websocket_debug_step( ) -> None: """Single step a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_step(hass, key, run_id) @@ -311,7 +311,7 @@ def websocket_debug_stop( ) -> None: """Stop a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_stop(hass, key, run_id) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8dd0ed8e91b..38080fffe6e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -129,6 +129,13 @@ async def _generate_trackables( if not trackable["device_id"]: return None + if "details" not in trackable: + _LOGGER.info( + "Tracker %s has no details and will be skipped. This happens for shared trackers", + trackable["device_id"], + ) + return None + tracker = client.tracker(trackable["device_id"]) tracker_details, hw_info, pos_report = await asyncio.gather( diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index df35301b373..769c8f6f9e1 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -14,6 +14,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -51,6 +52,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity): _attr_name = None _attr_supported_features = LightEntityFeature.TRANSITION + _fixed_color_mode: ColorMode | None = None def __init__( self, @@ -72,18 +74,16 @@ class TradfriLight(TradfriBaseEntity, LightEntity): self._hs_color = None # Calculate supported color modes - self._attr_supported_color_modes: set[ColorMode] = set() + modes: set[ColorMode] = {ColorMode.ONOFF} if self._device.light_control.can_set_color: - self._attr_supported_color_modes.add(ColorMode.HS) + modes.add(ColorMode.HS) if self._device.light_control.can_set_temp: - self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - if ( - not self._attr_supported_color_modes - and self._device.light_control.can_set_dimmer - ): - # Must be the only supported mode according to docs for - # ColorMode.BRIGHTNESS - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + modes.add(ColorMode.COLOR_TEMP) + if self._device.light_control.can_set_dimmer: + modes.add(ColorMode.BRIGHTNESS) + self._attr_supported_color_modes = filter_supported_color_modes(modes) + if len(self._attr_supported_color_modes) == 1: + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) if self._device_control: self._attr_min_mireds = self._device_control.min_mireds @@ -100,6 +100,15 @@ class TradfriLight(TradfriBaseEntity, LightEntity): return False return cast(bool, self._device_data.state) + @property + def color_mode(self) -> ColorMode | None: + """Return the color mode of the light.""" + if self._fixed_color_mode: + return self._fixed_color_mode + if self.hs_color: + return ColorMode.HS + return ColorMode.COLOR_TEMP + @property def brightness(self) -> int | None: """Return the brightness of the light.""" diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index a5257455e7a..9db27eda622 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -4,12 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pytrafikverket.exceptions import ( - InvalidAuthentication, - MultipleCamerasFound, - NoCameraFound, - UnknownError, -) +from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol @@ -17,7 +12,13 @@ from homeassistant import config_entries 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 -from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) from .const import DOMAIN @@ -28,34 +29,28 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 3 entry: config_entries.ConfigEntry | None + cameras: list[CameraInfo] + api_key: str async def validate_input( self, sensor_api: str, location: str - ) -> tuple[dict[str, str], str | None, str | None]: + ) -> tuple[dict[str, str], list[CameraInfo] | None]: """Validate input from user input.""" errors: dict[str, str] = {} - camera_info: CameraInfo | None = None - camera_location: str | None = None - camera_id: str | None = None + cameras: list[CameraInfo] | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) try: - camera_info = await camera_api.async_get_camera(location) + cameras = await camera_api.async_get_cameras(location) except NoCameraFound: errors["location"] = "invalid_location" - except MultipleCamerasFound: - errors["location"] = "more_locations" except InvalidAuthentication: errors["base"] = "invalid_auth" except UnknownError: errors["base"] = "cannot_connect" - if camera_info: - camera_id = camera_info.camera_id - camera_location = camera_info.camera_name or "Trafikverket Camera" - - return (errors, camera_location, camera_id) + return (errors, cameras) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -73,7 +68,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) + errors, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -106,17 +101,18 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors, camera_location, camera_id = await self.validate_input( - api_key, location - ) + errors, cameras = await self.validate_input(api_key, location) - if not errors: - assert camera_location - await self.async_set_unique_id(f"{DOMAIN}-{camera_id}") + if not errors and cameras: + if len(cameras) > 1: + self.cameras = cameras + self.api_key = api_key + return await self.async_step_multiple_cameras() + await self.async_set_unique_id(f"{DOMAIN}-{cameras[0].camera_id}") self._abort_if_unique_id_configured() return self.async_create_entry( - title=camera_location, - data={CONF_API_KEY: api_key, CONF_ID: camera_id}, + title=cameras[0].camera_name or "Trafikverket Camera", + data={CONF_API_KEY: api_key, CONF_ID: cameras[0].camera_id}, ) return self.async_show_form( @@ -129,3 +125,42 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_multiple_cameras( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle when multiple cameras.""" + + if user_input: + errors, cameras = await self.validate_input( + self.api_key, user_input[CONF_ID] + ) + + if not errors and cameras: + await self.async_set_unique_id(f"{DOMAIN}-{cameras[0].camera_id}") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=cameras[0].camera_name or "Trafikverket Camera", + data={CONF_API_KEY: self.api_key, CONF_ID: cameras[0].camera_id}, + ) + + camera_choices = [ + SelectOptionDict( + value=f"{camera_info.camera_id}", + label=f"{camera_info.camera_id} - {camera_info.camera_name} - {camera_info.location}", + ) + for camera_info in self.cameras + ] + + return self.async_show_form( + step_id="multiple_cameras", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=camera_choices, mode=SelectSelectorMode.LIST + ) + ), + } + ), + ) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index d7631ada680..ac8570d8a02 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.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 35dbbb1f540..e3a1ceec4c0 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -17,7 +17,13 @@ "location": "[%key:common::config_flow::data::location%]" }, "data_description": { - "location": "Equal or part of name, description or camera id" + "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result" + } + }, + "multiple_cameras": { + "description": "Result came back with multiple cameras", + "data": { + "id": "Choose camera" } } } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index e1c86038986..99feccf983f 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.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 83dd0e726ee..6a09821f729 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.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 1f27346b3a8..430d240761f 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.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/tts/icons.json b/homeassistant/components/tts/icons.json new file mode 100644 index 00000000000..cda5f877b25 --- /dev/null +++ b/homeassistant/components/tts/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:speaker-message" + } + }, + "services": { + "clear_cache": "mdi:delete", + "say": "mdi:speaker-message", + "speak": "mdi:speaker-message" + } +} diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ee084b77ef1..5a6874fb352 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,103 +1,87 @@ """Support for Tuya Smart devices.""" from __future__ import annotations -from typing import NamedTuple +import logging +from typing import Any, NamedTuple -import requests -from tuya_iot import ( - AuthType, - TuyaDevice, - TuyaDeviceListener, - TuyaDeviceManager, - TuyaHomeManager, - TuyaOpenAPI, - TuyaOpenMQ, +from tuya_sharing import ( + CustomerDevice, + Manager, + SharingDeviceListener, + SharingTokenListener, ) 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.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, LOGGER, PLATFORMS, + TUYA_CLIENT_ID, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, ) +# Suppress logs from the library, it logs unneeded on error +logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL) + class HomeAssistantTuyaData(NamedTuple): """Tuya data stored in the Home Assistant data object.""" - device_listener: TuyaDeviceListener - device_manager: TuyaDeviceManager - home_manager: TuyaHomeManager + manager: Manager + listener: SharingDeviceListener async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" - hass.data.setdefault(DOMAIN, {}) + if CONF_APP_TYPE in entry.data: + raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) - api = TuyaOpenAPI( - endpoint=entry.data[CONF_ENDPOINT], - access_id=entry.data[CONF_ACCESS_ID], - access_secret=entry.data[CONF_ACCESS_SECRET], - auth_type=auth_type, + token_listener = TokenListener(hass, entry) + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + token_listener, ) - api.set_dev_channel("hass") + listener = DeviceListener(hass, manager) + manager.add_device_listener(listener) + # Get all devices from Tuya try: - if auth_type == AuthType.CUSTOM: - response = await hass.async_add_executor_job( - api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - else: - response = await hass.async_add_executor_job( - api.connect, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTRY_CODE], - entry.data[CONF_APP_TYPE], - ) - except requests.exceptions.RequestException as err: - raise ConfigEntryNotReady(err) from err + await hass.async_add_executor_job(manager.update_device_cache) + except Exception as exc: # pylint: disable=broad-except + # While in general, we should avoid catching broad exceptions, + # we have no other way of detecting this case. + if "sign invalid" in str(exc): + msg = "Authentication failed. Please re-authenticate" + raise ConfigEntryAuthFailed(msg) from exc + raise - if response.get("success", False) is False: - raise ConfigEntryNotReady(response) - - tuya_mq = TuyaOpenMQ(api) - tuya_mq.start() - - device_ids: set[str] = set() - device_manager = TuyaDeviceManager(api, tuya_mq) - home_manager = TuyaHomeManager(api, tuya_mq, device_manager) - listener = DeviceListener(hass, device_manager, device_ids) - device_manager.add_device_listener(listener) - - hass.data[DOMAIN][entry.entry_id] = HomeAssistantTuyaData( - device_listener=listener, - device_manager=device_manager, - home_manager=home_manager, + # Connection is successful, store the manager & listener + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( + manager=manager, listener=listener ) - # Get devices & clean up device entities - await hass.async_add_executor_job(home_manager.update_device_cache) - await cleanup_device_registry(hass, device_manager) + # Cleanup device registry + await cleanup_device_registry(hass, manager) # Register known device IDs device_registry = dr.async_get(hass) - for device in device_manager.device_map.values(): + for device in manager.device_map.values(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, @@ -105,15 +89,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=device.name, model=f"{device.product_name} (unsupported)", ) - device_ids.add(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # If the device does not register any entities, the device does not need to subscribe + # So the subscription is here + await hass.async_add_executor_job(manager.refresh_mq) return True -async def cleanup_device_registry( - hass: HomeAssistant, device_manager: TuyaDeviceManager -) -> None: +async def cleanup_device_registry(hass: HomeAssistant, device_manager: Manager) -> None: """Remove deleted device registry entry if there are no remaining entities.""" device_registry = dr.async_get(hass) for dev_id, device_entry in list(device_registry.devices.items()): @@ -125,59 +109,58 @@ async def cleanup_device_registry( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" - unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload: - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - hass_data.device_manager.mq.stop() - hass_data.device_manager.remove_device_listener(hass_data.device_listener) - - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + tuya: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + if tuya.manager.mq is not None: + tuya.manager.mq.stop() + tuya.manager.remove_device_listener(tuya.listener) + del hass.data[DOMAIN][entry.entry_id] + return unload_ok -class DeviceListener(TuyaDeviceListener): +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry. + + This will revoke the credentials from Tuya. + """ + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + ) + await hass.async_add_executor_job(manager.unload) + + +class DeviceListener(SharingDeviceListener): """Device Update Listener.""" def __init__( self, hass: HomeAssistant, - device_manager: TuyaDeviceManager, - device_ids: set[str], + manager: Manager, ) -> None: """Init DeviceListener.""" self.hass = hass - self.device_manager = device_manager - self.device_ids = device_ids + self.manager = manager - def update_device(self, device: TuyaDevice) -> None: + def update_device(self, device: CustomerDevice) -> None: """Update device status.""" - if device.id in self.device_ids: - LOGGER.debug( - "Received update for device %s: %s", - device.id, - self.device_manager.device_map[device.id].status, - ) - dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") + LOGGER.debug( + "Received update for device %s: %s", + device.id, + self.manager.device_map[device.id].status, + ) + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") - def add_device(self, device: TuyaDevice) -> None: + def add_device(self, device: CustomerDevice) -> None: """Add device added listener.""" # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) - self.device_ids.add(device.id) dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) - device_manager = self.device_manager - device_manager.mq.stop() - tuya_mq = TuyaOpenMQ(device_manager.api) - tuya_mq.start() - - device_manager.mq = tuya_mq - tuya_mq.add_message_listener(device_manager.on_message) - def remove_device(self, device_id: str) -> None: """Add device removed listener.""" self.hass.add_job(self.async_remove_device, device_id) @@ -192,4 +175,36 @@ class DeviceListener(TuyaDeviceListener): ) if device_entry is not None: device_registry.async_remove_device(device_entry.id) - self.device_ids.discard(device_id) + + +class TokenListener(SharingTokenListener): + """Token listener for upstream token updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Init TokenListener.""" + self.hass = hass + self.entry = entry + + def update_token(self, token_info: dict[str, Any]) -> None: + """Update token info in config entry.""" + data = { + **self.entry.data, + CONF_TOKEN_INFO: { + "t": token_info["t"], + "uid": token_info["uid"], + "expire_time": token_info["expire_time"], + "access_token": token_info["access_token"], + "refresh_token": token_info["refresh_token"], + }, + } + + @callback + def async_update_entry() -> None: + """Update config entry.""" + self.hass.config_entries.async_update_entry(self.entry, data=data) + + self.hass.add_job(async_update_entry) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index cd92e62b864..681f025f57b 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -68,18 +68,16 @@ async def async_setup_entry( """Discover and add a discovered Tuya siren.""" entities: list[TuyaAlarmEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := ALARM.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaAlarmEntity( - device, hass_data.device_manager, description - ) + TuyaAlarmEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -94,8 +92,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: AlarmControlPanelEntityDescription, ) -> None: """Init Tuya Alarm.""" diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 3aae417aac7..7c4e213fe65 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -7,7 +7,7 @@ import json import struct from typing import Any, Literal, Self, overload -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -135,9 +135,11 @@ class TuyaEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init TuyaHaEntity.""" self._attr_unique_id = f"tuya.{device.id}" + # TuyaEntity initialize mq can subscribe + device.set_up = True self.device = device self.device_manager = device_manager diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 8e934ae6593..5664801d76e 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -354,20 +354,20 @@ async def async_setup_entry( """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: entities.append( TuyaBinarySensorEntity( - device, hass_data.device_manager, description + device, hass_data.manager, description ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -381,8 +381,8 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaBinarySensorEntityDescription, ) -> None: """Init Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 4c73b70c29a..5b936b305fb 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,7 +1,7 @@ """Support for Tuya buttons.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -74,19 +74,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya buttons.""" entities: list[TuyaButtonEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BUTTONS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaButtonEntity( - device, hass_data.device_manager, description - ) + TuyaButtonEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -98,8 +96,8 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: ButtonEntityDescription, ) -> None: """Init Tuya button.""" diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 72216057aff..07c4adb8889 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,7 +1,7 @@ """Support for Tuya cameras.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature @@ -34,13 +34,13 @@ async def async_setup_entry( """Discover and add a discovered Tuya camera.""" entities: list[TuyaCameraEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category in CAMERAS: - entities.append(TuyaCameraEntity(device, hass_data.device_manager)) + entities.append(TuyaCameraEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -56,8 +56,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Camera.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index b8c66c5cc35..45adb532705 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( SWING_BOTH, @@ -98,18 +98,19 @@ async def async_setup_entry( """Discover and add a discovered Tuya climate.""" entities: list[TuyaClimateEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, - hass_data.device_manager, + hass_data.manager, CLIMATE_DESCRIPTIONS[device.category], + hass.config.units.temperature_unit, ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -126,12 +127,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaClimateEntityDescription, + system_temperature_unit: UnitOfTemperature, ) -> None: """Determine which values to use.""" self._attr_target_temperature_step = 1.0 @@ -156,8 +159,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ): prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT - # Default to Celsius - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + # Default to System Temperature Unit + self._attr_temperature_unit = system_temperature_unit # Figure out current temperature, use preferred unit or what is available celsius_type = self.find_dpcode( @@ -275,6 +278,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): self._attr_swing_modes.append(SWING_VERTICAL) + if DPCode.SWITCH in self.device.function: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() @@ -474,23 +482,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def turn_on(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": True}]) - return - - # Fake turn on - for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL): - if mode not in self.hvac_modes: - continue - self.set_hvac_mode(mode) - break + self._send_command([{"code": DPCode.SWITCH, "value": True}]) def turn_off(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": False}]) - return - - # Fake turn off - if HVACMode.OFF in self.hvac_modes: - self.set_hvac_mode(HVACMode.OFF) + self._send_command([{"code": DPCode.SWITCH, "value": False}]) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index f933ac84519..e0ac5375b00 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,115 +1,63 @@ """Config flow for Tuya.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from tuya_iot import AuthType, TuyaOpenAPI +from tuya_sharing import LoginControl import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, - LOGGER, - SMARTLIFE_APP, - TUYA_COUNTRIES, + TUYA_CLIENT_ID, TUYA_RESPONSE_CODE, TUYA_RESPONSE_MSG, - TUYA_RESPONSE_PLATFORM_URL, + TUYA_RESPONSE_QR_CODE, TUYA_RESPONSE_RESULT, TUYA_RESPONSE_SUCCESS, - TUYA_SMART_APP, + TUYA_SCHEMA, ) -class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Tuya Config Flow.""" +class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): + """Tuya config flow.""" - @staticmethod - def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: - """Try login.""" - response = {} + __user_code: str + __qr_code: str + __reauth_entry: ConfigEntry | None = None - country = [ - country - for country in TUYA_COUNTRIES - if country.name == user_input[CONF_COUNTRY_CODE] - ][0] + def __init__(self) -> None: + """Initialize the config flow.""" + self.__login_control = LoginControl() - data = { - CONF_ENDPOINT: country.endpoint, - CONF_AUTH_TYPE: AuthType.CUSTOM, - CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], - CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_COUNTRY_CODE: country.country_code, - } - - for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): - data[CONF_APP_TYPE] = app_type - if data[CONF_APP_TYPE] == "": - data[CONF_AUTH_TYPE] = AuthType.CUSTOM - else: - data[CONF_AUTH_TYPE] = AuthType.SMART_HOME - - api = TuyaOpenAPI( - endpoint=data[CONF_ENDPOINT], - access_id=data[CONF_ACCESS_ID], - access_secret=data[CONF_ACCESS_SECRET], - auth_type=data[CONF_AUTH_TYPE], - ) - api.set_dev_channel("hass") - - response = api.connect( - username=data[CONF_USERNAME], - password=data[CONF_PASSWORD], - country_code=data[CONF_COUNTRY_CODE], - schema=data[CONF_APP_TYPE], - ) - - LOGGER.debug("Response %s", response) - - if response.get(TUYA_RESPONSE_SUCCESS, False): - break - - return response, data - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Step user.""" errors = {} placeholders = {} if user_input is not None: - response, data = await self.hass.async_add_executor_job( - self._try_login, user_input + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] ) + if success: + return await self.async_step_scan() - if response.get(TUYA_RESPONSE_SUCCESS, False): - if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( - TUYA_RESPONSE_PLATFORM_URL - ): - data[CONF_ENDPOINT] = endpoint - - data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value - - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=data, - ) errors["base"] = "login_error" placeholders = { - TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), - TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), } - - if user_input is None: + else: user_input = {} return self.async_show_form( @@ -117,27 +65,146 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required( - CONF_COUNTRY_CODE, - default=user_input.get(CONF_COUNTRY_CODE, "United States"), - ): vol.In( - # We don't pass a dict {code:name} because country codes can be duplicate. - [country.name for country in TUYA_COUNTRIES] - ), - vol.Required( - CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") - ): str, - vol.Required( - CONF_ACCESS_SECRET, - default=user_input.get(CONF_ACCESS_SECRET, ""), - ): str, - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") ): str, } ), errors=errors, description_placeholders=placeholders, ) + + async def async_step_scan( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step scan.""" + if user_input is None: + return self.async_show_form( + step_id="scan", + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), + ) + + ret, info = await self.hass.async_add_executor_job( + self.__login_control.login_result, + self.__qr_code, + TUYA_CLIENT_ID, + self.__user_code, + ) + if not ret: + # Try to get a new QR code on failure + await self.__async_get_qr_code(self.__user_code) + return self.async_show_form( + step_id="scan", + errors={"base": "login_error"}, + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), + description_placeholders={ + TUYA_RESPONSE_MSG: info.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: info.get(TUYA_RESPONSE_CODE, 0), + }, + ) + + entry_data = { + CONF_USER_CODE: self.__user_code, + CONF_TOKEN_INFO: { + "t": info["t"], + "uid": info["uid"], + "expire_time": info["expire_time"], + "access_token": info["access_token"], + "refresh_token": info["refresh_token"], + }, + CONF_TERMINAL_ID: info[CONF_TERMINAL_ID], + CONF_ENDPOINT: info[CONF_ENDPOINT], + } + + if self.__reauth_entry: + return self.async_update_reload_and_abort( + self.__reauth_entry, + data=entry_data, + ) + + return self.async_create_entry( + title=info.get("username"), + data=entry_data, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Tuya.""" + self.__reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + if self.__reauth_entry and CONF_USER_CODE in self.__reauth_entry.data: + success, _ = await self.__async_get_qr_code( + self.__reauth_entry.data[CONF_USER_CODE] + ) + if success: + return await self.async_step_scan() + + return await self.async_step_reauth_user_code() + + async def async_step_reauth_user_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with a Tuya.""" + errors = {} + placeholders = {} + + if user_input is not None: + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] + ) + if success: + return await self.async_step_scan() + + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), + } + else: + user_input = {} + + return self.async_show_form( + step_id="reauth_user_code", + data_schema=vol.Schema( + { + vol.Required( + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") + ): str, + } + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def __async_get_qr_code(self, user_code: str) -> tuple[bool, dict[str, Any]]: + """Get the QR code.""" + response = await self.hass.async_add_executor_job( + self.__login_control.qr_code, + TUYA_CLIENT_ID, + TUYA_SCHEMA, + user_code, + ) + if success := response.get(TUYA_RESPONSE_SUCCESS, False): + self.__user_code = user_code + self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE] + return success, response diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 4cdca8f3904..8f15114aa80 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -6,8 +6,6 @@ from dataclasses import dataclass, field from enum import StrEnum import logging -from tuya_iot import TuyaCloudOpenAPIEndpoint - from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -31,24 +29,24 @@ from homeassistant.const import ( DOMAIN = "tuya" LOGGER = logging.getLogger(__package__) -CONF_AUTH_TYPE = "auth_type" -CONF_PROJECT_TYPE = "tuya_project_type" -CONF_ENDPOINT = "endpoint" -CONF_ACCESS_ID = "access_id" -CONF_ACCESS_SECRET = "access_secret" CONF_APP_TYPE = "tuya_app_type" +CONF_ENDPOINT = "endpoint" +CONF_TERMINAL_ID = "terminal_id" +CONF_TOKEN_INFO = "token_info" +CONF_USER_CODE = "user_code" +CONF_USERNAME = "username" + +TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" +TUYA_SCHEMA = "haauthorize" TUYA_DISCOVERY_NEW = "tuya_discovery_new" TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" TUYA_RESPONSE_CODE = "code" -TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_QR_CODE = "qrcode" +TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_SUCCESS = "success" -TUYA_RESPONSE_PLATFORM_URL = "platform_url" - -TUYA_SMART_APP = "tuyaSmart" -SMARTLIFE_APP = "smartlife" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -570,259 +568,3 @@ for uom in UNITS: DEVICE_CLASS_UNITS.setdefault(device_class, {})[uom.unit] = uom for unit_alias in uom.aliases: DEVICE_CLASS_UNITS[device_class][unit_alias] = uom - - -@dataclass -class Country: - """Describe a supported country.""" - - name: str - country_code: str - endpoint: str = TuyaCloudOpenAPIEndpoint.AMERICA - - -# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb -TUYA_COUNTRIES = [ - Country("Afghanistan", "93", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Albania", "355", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Algeria", "213", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("American Samoa", "1-684", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Andorra", "376", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Angola", "244", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Anguilla", "1-264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Antarctica", "672", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Antigua and Barbuda", "1-268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Argentina", "54", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Armenia", "374", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Aruba", "297", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Australia", "61", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Austria", "43", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Azerbaijan", "994", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahamas", "1-242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahrain", "973", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bangladesh", "880", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Barbados", "1-246", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belarus", "375", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belgium", "32", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belize", "501", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Benin", "229", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bermuda", "1-441", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bhutan", "975", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bolivia", "591", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Bosnia and Herzegovina", "387", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Botswana", "267", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brazil", "55", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Indian Ocean Territory", "246", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Virgin Islands", "1-284", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brunei", "673", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bulgaria", "359", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burkina Faso", "226", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burundi", "257", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cambodia", "855", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cameroon", "237", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Canada", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Capo Verde", "238", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cayman Islands", "1-345", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Central African Republic", "236", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chad", "235", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chile", "56", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("China", "86", TuyaCloudOpenAPIEndpoint.CHINA), - Country("Christmas Island", "61"), - Country("Cocos Islands", "61"), - Country("Colombia", "57", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Comoros", "269", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cook Islands", "682", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Costa Rica", "506", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Croatia", "385", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cuba", "53"), - Country("Curacao", "599", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Cyprus", "357", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Czech Republic", "420", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Democratic Republic of the Congo", "243", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Denmark", "45", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Djibouti", "253", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominica", "1-767", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominican Republic", "1-809", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("East Timor", "670", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Ecuador", "593", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Egypt", "20", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("El Salvador", "503", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Equatorial Guinea", "240", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Eritrea", "291", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Estonia", "372", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ethiopia", "251", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Falkland Islands", "500", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Faroe Islands", "298", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Fiji", "679", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Finland", "358", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("France", "33", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("French Polynesia", "689", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gabon", "241", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gambia", "220", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Georgia", "995", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Germany", "49", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ghana", "233", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gibraltar", "350", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greece", "30", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greenland", "299", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Grenada", "1-473", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guam", "1-671", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guatemala", "502", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guernsey", "44-1481"), - Country("Guinea", "224"), - Country("Guinea-Bissau", "245", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guyana", "592", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Haiti", "509", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Honduras", "504", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Hong Kong", "852", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Hungary", "36", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Iceland", "354", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("India", "91", TuyaCloudOpenAPIEndpoint.INDIA), - Country("Indonesia", "62", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Iran", "98"), - Country("Iraq", "964", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ireland", "353", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Isle of Man", "44-1624"), - Country("Israel", "972", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Italy", "39", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ivory Coast", "225", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Jamaica", "1-876", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Japan", "81", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Jersey", "44-1534"), - Country("Jordan", "962", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kazakhstan", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kenya", "254", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kiribati", "686", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Kosovo", "383"), - Country("Kuwait", "965", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kyrgyzstan", "996", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Laos", "856", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Latvia", "371", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lebanon", "961", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lesotho", "266", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liberia", "231", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Libya", "218", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liechtenstein", "423", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lithuania", "370", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Luxembourg", "352", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Macao", "853", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Macedonia", "389", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Madagascar", "261", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malawi", "265", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malaysia", "60", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Maldives", "960", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mali", "223", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malta", "356", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Marshall Islands", "692", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritania", "222", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritius", "230", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mayotte", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mexico", "52", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Micronesia", "691", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Moldova", "373", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Monaco", "377", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mongolia", "976", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montenegro", "382", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montserrat", "1-664", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Morocco", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mozambique", "258", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Myanmar", "95", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Namibia", "264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nauru", "674", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nepal", "977", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands", "31", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands Antilles", "599"), - Country("New Caledonia", "687", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("New Zealand", "64", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nicaragua", "505", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niger", "227", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nigeria", "234", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niue", "683", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("North Korea", "850"), - Country("Northern Mariana Islands", "1-670", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Norway", "47", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Oman", "968", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Pakistan", "92", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palau", "680", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palestine", "970", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Panama", "507", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Papua New Guinea", "675", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Paraguay", "595", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Peru", "51", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Philippines", "63", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Pitcairn", "64"), - Country("Poland", "48", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Portugal", "351", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Puerto Rico", "1-787, 1-939", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Qatar", "974", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Republic of the Congo", "242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Reunion", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Romania", "40", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Russia", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Rwanda", "250", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Barthelemy", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Helena", "290"), - Country("Saint Kitts and Nevis", "1-869", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Lucia", "1-758", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Martin", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Pierre and Miquelon", "508", TuyaCloudOpenAPIEndpoint.EUROPE), - Country( - "Saint Vincent and the Grenadines", "1-784", TuyaCloudOpenAPIEndpoint.EUROPE - ), - Country("Samoa", "685", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("San Marino", "378", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sao Tome and Principe", "239", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Saudi Arabia", "966", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Senegal", "221", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Serbia", "381", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Seychelles", "248", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sierra Leone", "232", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Singapore", "65", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sint Maarten", "1-721", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Slovakia", "421", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Slovenia", "386", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Solomon Islands", "677", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Somalia", "252", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Africa", "27", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Korea", "82", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("South Sudan", "211"), - Country("Spain", "34", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sri Lanka", "94", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sudan", "249"), - Country("Suriname", "597", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Svalbard and Jan Mayen", "4779", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Swaziland", "268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sweden", "46", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Switzerland", "41", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Syria", "963"), - Country("Taiwan", "886", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tajikistan", "992", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tanzania", "255", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Thailand", "66", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Togo", "228", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tokelau", "690", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tonga", "676", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Trinidad and Tobago", "1-868", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tunisia", "216", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkey", "90", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkmenistan", "993", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turks and Caicos Islands", "1-649", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tuvalu", "688", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("U.S. Virgin Islands", "1-340", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Uganda", "256", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ukraine", "380", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Arab Emirates", "971", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Kingdom", "44", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United States", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uruguay", "598", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uzbekistan", "998", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Vanuatu", "678", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vatican", "379", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Venezuela", "58", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vietnam", "84", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Wallis and Futuna", "681", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Western Sahara", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Yemen", "967", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zambia", "260", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zimbabwe", "263", TuyaCloudOpenAPIEndpoint.EUROPE), -] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 46bd0721ccb..912087d2c8c 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( ATTR_POSITION, @@ -152,7 +152,7 @@ async def async_setup_entry( """Discover and add a discovered tuya cover.""" entities: list[TuyaCoverEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := COVERS.get(device.category): for description in descriptions: if ( @@ -160,14 +160,12 @@ async def async_setup_entry( or description.key in device.status_range ): entities.append( - TuyaCoverEntity( - device, hass_data.device_manager, description - ) + TuyaCoverEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -184,8 +182,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaCoverEntityDescription, ) -> None: """Init Tuya Cover.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index adac97174b9..cdd0d5ed51c 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -5,18 +5,17 @@ from contextlib import suppress import json from typing import Any, cast -from tuya_iot import TuyaDevice +from tuya_sharing import CustomerDevice 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_ENDPOINT, DOMAIN, DPCode +from .const import DOMAIN, DPCode async def async_get_config_entry_diagnostics( @@ -43,14 +42,12 @@ def _async_get_diagnostics( hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] mqtt_connected = None - if hass_data.home_manager.mq.client: - mqtt_connected = hass_data.home_manager.mq.client.is_connected() + if hass_data.manager.mq.client: + mqtt_connected = hass_data.manager.mq.client.is_connected() data = { - "endpoint": entry.data[CONF_ENDPOINT], - "auth_type": entry.data[CONF_AUTH_TYPE], - "country_code": entry.data[CONF_COUNTRY_CODE], - "app_type": entry.data[CONF_APP_TYPE], + "endpoint": hass_data.manager.customer_api.endpoint, + "terminal_id": hass_data.manager.terminal_id, "mqtt_connected": mqtt_connected, "disabled_by": entry.disabled_by, "disabled_polling": entry.pref_disable_polling, @@ -59,13 +56,13 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] data |= _async_device_as_dict( - hass, hass_data.device_manager.device_map[tuya_device_id] + hass, hass_data.manager.device_map[tuya_device_id] ) else: data.update( devices=[ _async_device_as_dict(hass, device) - for device in hass_data.device_manager.device_map.values() + for device in hass_data.manager.device_map.values() ] ) @@ -73,13 +70,15 @@ def _async_get_diagnostics( @callback -def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, Any]: +def _async_device_as_dict( + hass: HomeAssistant, device: CustomerDevice +) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" # Base device information, without sensitive information. data = { + "id": device.id, "name": device.name, - "model": device.model if hasattr(device, "model") else None, "category": device.category, "product_id": device.product_id, "product_name": device.product_name, @@ -93,6 +92,8 @@ def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, "status_range": {}, "status": {}, "home_assistant": {}, + "set_up": device.set_up, + "support_local": device.support_local, } # Gather Tuya states diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 210cc5c7518..0971462e450 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -44,12 +44,12 @@ async def async_setup_entry( """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in TUYA_SUPPORT_TYPE: - entities.append(TuyaFanEntity(device, hass_data.device_manager)) + entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -69,8 +69,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index a8008ced953..7cc4fee03fc 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( HumidifierDeviceClass, @@ -65,14 +65,14 @@ async def async_setup_entry( """Discover and add a discovered Tuya (de)humidifier.""" entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if description := HUMIDIFIERS.get(device.category): entities.append( - TuyaHumidifierEntity(device, hass_data.device_manager, description) + TuyaHumidifierEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -90,8 +90,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaHumidifierEntityDescription, ) -> None: """Init Tuya (de)humidifier.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 8e98e8d6a41..98d704326ae 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field import json from typing import Any, cast -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -118,6 +118,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_1, ), ), + # Filament Light + # Based on data from https://github.com/home-assistant/core/issues/106703 + # Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 + # As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc + "dsd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + ), + ), # Ceiling Fan Light # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v "fsd": ( @@ -401,19 +413,17 @@ async def async_setup_entry( """Discover and add a discovered tuya light.""" entities: list[TuyaLightEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaLightEntity( - device, hass_data.device_manager, description - ) + TuyaLightEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -435,8 +445,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaLightEntityDescription, ) -> None: """Init TuyaHaLight.""" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index a6d0a28d36a..305a74160de 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-iot-py-sdk==0.6.6"] + "requirements": ["tuya-device-sharing-sdk==0.1.9"] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 5e7bdcc260a..8fc55d2c230 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -1,7 +1,7 @@ """Support for Tuya number.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( NumberDeviceClass, @@ -323,19 +323,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya number.""" entities: list[TuyaNumberEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := NUMBERS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaNumberEntity( - device, hass_data.device_manager, description - ) + TuyaNumberEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -349,8 +347,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: NumberEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 289e319df1b..8db3ef60658 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaHomeManager, TuyaScene +from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry @@ -20,10 +20,8 @@ async def async_setup_entry( ) -> None: """Set up Tuya scenes.""" hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - scenes = await hass.async_add_executor_job(hass_data.home_manager.query_scenes) - async_add_entities( - TuyaSceneEntity(hass_data.home_manager, scene) for scene in scenes - ) + scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) + async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) class TuyaSceneEntity(Scene): @@ -33,7 +31,7 @@ class TuyaSceneEntity(Scene): _attr_has_entity_name = True _attr_name = None - def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + def __init__(self, home_manager: Manager, scene: SharingScene) -> None: """Init Tuya Scene.""" super().__init__() self._attr_unique_id = f"tys{scene.scene_id}" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index bc44ddf479c..5d712767697 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -1,7 +1,7 @@ """Support for Tuya select.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -356,19 +356,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya select.""" entities: list[TuyaSelectEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SELECTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSelectEntity( - device, hass_data.device_manager, description - ) + TuyaSelectEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -380,8 +378,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SelectEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 62b59cb8ed9..80c76a0c253 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager -from tuya_iot.device import TuyaDeviceStatusRange +from tuya_sharing import CustomerDevice, Manager +from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( SensorDeviceClass, @@ -1112,19 +1112,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya sensor.""" entities: list[TuyaSensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SENSORS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSensorEntity( - device, hass_data.device_manager, description - ) + TuyaSensorEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -1136,15 +1134,15 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): entity_description: TuyaSensorEntityDescription - _status_range: TuyaDeviceStatusRange | None = None + _status_range: DeviceStatusRange | None = None _type: DPType | None = None _type_data: IntegerTypeData | EnumTypeData | None = None _uom: UnitOfMeasurement | None = None def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaSensorEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index c2dc8cea99b..baba339318d 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.siren import ( SirenEntity, @@ -57,19 +57,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya siren.""" entities: list[TuyaSirenEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SIRENS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSirenEntity( - device, hass_data.device_manager, description - ) + TuyaSirenEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -84,8 +82,8 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SirenEntityDescription, ) -> None: """Init Tuya Siren.""" diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index e9b13e10a95..cfce12273a0 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1,20 +1,28 @@ { "config": { "step": { - "user": { - "description": "Enter your Tuya credentials", + "reauth_user_code": { + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", "data": { - "country_code": "Country", - "access_id": "Tuya IoT Access ID", - "access_secret": "Tuya IoT Access Secret", - "username": "Account", - "password": "[%key:common::config_flow::data::password%]" + "user_code": "User code" } + }, + "user": { + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "data": { + "user_code": "User code" + } + }, + "scan": { + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "login_error": "Login error ({code}): {msg}" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -817,29 +825,5 @@ "name": "Sterilization" } } - }, - "issues": { - "service_deprecation_turn_off": { - "title": "Tuya vacuum support for vacuum.turn_off is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::tuya::issues::service_deprecation_turn_off::title%]", - "description": "Tuya vacuum support for the vacuum.turn_off service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.stop and select submit below to mark this issue as resolved." - } - } - } - }, - "service_deprecation_turn_on": { - "title": "Tuya vacuum support for vacuum.turn_on is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::tuya::issues::service_deprecation_turn_on::title%]", - "description": "Tuya vacuum support for the vacuum.turn_on service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.start and select submit below to mark this issue as resolved." - } - } - } - } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ba304b4069e..a89dbbd7132 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( SwitchDeviceClass, @@ -730,19 +730,17 @@ async def async_setup_entry( """Discover and add a discovered tuya sensor.""" entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SWITCHES.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSwitchEntity( - device, hass_data.device_manager, description - ) + TuyaSwitchEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -754,8 +752,8 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SwitchEntityDescription, ) -> None: """Init TuyaHaSwitch.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index b332be7de2d..9ebfe899518 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( STATE_CLEANING, @@ -15,7 +15,6 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,12 +61,12 @@ async def async_setup_entry( """Discover and add a discovered Tuya vacuum.""" entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category == "sd": - entities.append(TuyaVacuumEntity(device, hass_data.device_manager)) + entities.append(TuyaVacuumEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -81,7 +80,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): _battery_level: IntegerTypeData | None = None _attr_name = None - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init Tuya vacuum.""" super().__init__(device, device_manager) @@ -105,11 +104,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.SEEK, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER, prefer_function=True): - self._attr_supported_features |= ( - VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF - ) - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -151,34 +145,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): return None return TUYA_STATUS_TO_HA.get(status) - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self._send_command([{"code": DPCode.POWER, "value": True}]) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_on", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_on", - ) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self._send_command([{"code": DPCode.POWER, "value": False}]) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_off", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_off", - ) - def start(self, **kwargs: Any) -> None: """Start the device.""" self._send_command([{"code": DPCode.POWER_GO, "value": True}]) diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index eb24e5d9a78..1132bd56b72 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -46,7 +46,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Ukraine Alarm API.""" def __init__( diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 4337899a50f..e435b68fc39 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: api = await get_unifi_controller(hass, config_entry.data) - controller = UniFiController(hass, config_entry, api) - await controller.initialize() except CannotConnect as err: raise ConfigEntryNotReady from err @@ -45,7 +43,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err + controller = UniFiController(hass, config_entry, api) + await controller.initialize() hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) controller.async_update_device_registry() diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 833d2001980..eb127a5dfd9 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -75,6 +75,7 @@ from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) +CHECK_WEBSOCKET_INTERVAL = timedelta(minutes=1) class UniFiController: @@ -89,6 +90,7 @@ class UniFiController: self.api = api self.ws_task: asyncio.Task | None = None + self._cancel_websocket_check: CALLBACK_TYPE | None = None self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] @@ -275,6 +277,9 @@ class UniFiController: self._cancel_heartbeat_check = async_track_time_interval( self.hass, self._async_check_for_stale, CHECK_HEARTBEAT_INTERVAL ) + self._cancel_websocket_check = async_track_time_interval( + self.hass, self._async_watch_websocket, CHECK_WEBSOCKET_INTERVAL + ) @callback def async_heartbeat( @@ -411,6 +416,14 @@ class UniFiController: ): self.hass.loop.call_later(RETRY_TIMER, self.reconnect) + @callback + def _async_watch_websocket(self, now: datetime) -> None: + """Watch timestamp for last received websocket message.""" + LOGGER.debug( + "Last received websocket timestamp: %s", + self.api.connectivity.ws_message_received, + ) + @callback def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. @@ -450,6 +463,10 @@ class UniFiController: self._cancel_heartbeat_check() self._cancel_heartbeat_check = None + if self._cancel_websocket_check: + self._cancel_websocket_check() + self._cancel_websocket_check = None + if self._cancel_poe_command: self._cancel_poe_command() self._cancel_poe_command = None @@ -501,6 +518,7 @@ async def get_unifi_controller( except ( asyncio.TimeoutError, aiounifi.BadGateway, + aiounifi.Forbidden, aiounifi.ServiceUnavailable, aiounifi.RequestError, aiounifi.ResponseError, @@ -510,14 +528,6 @@ 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/manifest.json b/homeassistant/components/unifi/manifest.json index 90b4421f164..f69dffc2d57 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==69"], + "requirements": ["aiounifi==70"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index c7b851a8fbb..a0cd3a7f1e7 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -7,7 +7,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta +from decimal import Decimal from typing import Generic from aiounifi.interfaces.api_handlers import ItemEvent @@ -32,8 +33,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower -from homeassistant.core import HomeAssistant, callback +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 +from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util from .const import DEVICE_STATES @@ -109,11 +112,22 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: @callback def async_device_uptime_value_fn( controller: UniFiController, device: Device -) -> datetime: - """Calculate the uptime of the device.""" - return (dt_util.now() - timedelta(seconds=device.uptime)).replace( - second=0, microsecond=0 - ) +) -> datetime | None: + """Calculate the approximate time the device started (based on uptime returned from API, in seconds).""" + if device.uptime <= 0: + # Library defaults to 0 if uptime is not provided, e.g. when offline + return None + return (dt_util.now() - timedelta(seconds=device.uptime)).replace(microsecond=0) + + +@callback +def async_device_uptime_value_changed_fn( + old: StateType | date | datetime | Decimal, new: datetime | float | str | None +) -> bool: + """Reject the new uptime value if it's too similar to the old one. Avoids unwanted fluctuation.""" + if isinstance(old, datetime) and isinstance(new, datetime): + return new != old and abs((new - old).total_seconds()) > 120 + return old is None or (new != old) @callback @@ -132,6 +146,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 +@callback +def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client was last seen recently.""" + client = controller.api.clients[obj_id] + + if ( + dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + > controller.option_detection_time + ): + return False + + return True + + @dataclass(frozen=True) class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -153,6 +181,13 @@ class UnifiSensorEntityDescription( ): """Class describing UniFi sensor entity.""" + is_connected_fn: Callable[[UniFiController, str], bool] | None = None + # Custom function to determine whether a state change should be recorded + value_changed_fn: Callable[ + [StateType | date | datetime | Decimal, datetime | float | str | None], + bool, + ] = lambda old, new: old != new + ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( @@ -169,6 +204,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, + is_connected_fn=async_client_is_connected_fn, name_fn=lambda _: "RX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, @@ -190,6 +226,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, + is_connected_fn=async_client_is_connected_fn, name_fn=lambda _: "TX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, @@ -330,6 +367,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", value_fn=async_device_uptime_value_fn, + value_changed_fn=async_device_uptime_value_changed_fn, ), UnifiSensorEntityDescription[Devices, Device]( key="Device temperature", @@ -388,6 +426,16 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] + @callback + def _make_disconnected(self, *_: core_Event) -> None: + """No heart beat by device. + + Set sensor as unavailable when client device is disconnected + """ + if self._attr_available: + self._attr_available = False + self.async_write_ha_state() + @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: """Update entity state. @@ -396,5 +444,38 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """ description = self.entity_description obj = description.object_fn(self.controller.api, self._obj_id) - if (value := description.value_fn(self.controller, obj)) != self.native_value: + # Update the value only if value is considered to have changed relative to its previous state + if description.value_changed_fn( + self.native_value, (value := description.value_fn(self.controller, obj)) + ): self._attr_native_value = value + + if description.is_connected_fn is not None: + # Send heartbeat if client is connected + if description.is_connected_fn(self.controller, self._obj_id): + self.controller.async_heartbeat( + self._attr_unique_id, + dt_util.utcnow() + self.controller.option_detection_time, + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if self.entity_description.is_connected_fn is not None: + # Register callback for missed heartbeat + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", + self._make_disconnected, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect object when removed.""" + await super().async_will_remove_from_hass() + + if self.entity_description.is_connected_fn is not None: + # Remove heartbeat registration + self.controller.async_heartbeat(self._attr_unique_id) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index b73e4669fbd..66767224de2 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses import logging +from typing import Any from pyunifiprotect.data import ( NVR, @@ -42,14 +43,14 @@ _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): @@ -209,6 +210,69 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_co_detection_on", ufp_perm=PermRequired.NO_WRITE, ), + ProtectBinaryEntityDescription( + key="smart_siren", + name="Detections: Siren", + icon="mdi:alarm-bell", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_siren", + ufp_value="is_siren_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_baby_cry", + name="Detections: Baby Cry", + icon="mdi:cradle", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_baby_cry", + ufp_value="is_baby_cry_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_speak", + name="Detections: Speaking", + icon="mdi:account-voice", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_speaking", + ufp_value="is_speaking_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_bark", + name="Detections: Barking", + icon="mdi:dog", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_bark", + ufp_value="is_bark_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_car_alarm", + name="Detections: Car Alarm", + icon="mdi:car", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_car_alarm", + ufp_value="is_car_alarm_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_car_horn", + name="Detections: Car Horn", + icon="mdi:bugle", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_car_horn", + ufp_value="is_car_horn_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_glass_break", + name="Detections: Glass Break", + icon="mdi:glass-fragile", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_glass_break", + ufp_value="is_glass_break_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -259,6 +323,13 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_opened", ufp_enabled="is_contact_sensor_enabled", ), + ProtectBinaryEntityDescription( + key="leak", + name="Leak", + device_class=BinarySensorDeviceClass.MOISTURE, + ufp_value="is_leak_detected", + ufp_enabled="is_leak_sensor_enabled", + ), ProtectBinaryEntityDescription( key="battery_low", name="Battery low", @@ -407,6 +478,69 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", ), + ProtectBinaryEventEntityDescription( + key="smart_audio_siren", + name="Siren Detected", + icon="mdi:alarm-bell", + ufp_value="is_siren_currently_detected", + ufp_required_field="can_detect_siren", + ufp_enabled="is_siren_detection_on", + ufp_event_obj="last_siren_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_baby_cry", + name="Baby Cry Detected", + icon="mdi:cradle", + ufp_value="is_baby_cry_currently_detected", + ufp_required_field="can_detect_baby_cry", + ufp_enabled="is_baby_cry_detection_on", + ufp_event_obj="last_baby_cry_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_speak", + name="Speaking Detected", + icon="mdi:account-voice", + ufp_value="is_speaking_currently_detected", + ufp_required_field="can_detect_speaking", + ufp_enabled="is_speaking_detection_on", + ufp_event_obj="last_speaking_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_bark", + name="Barking Detected", + icon="mdi:dog", + ufp_value="is_bark_currently_detected", + ufp_required_field="can_detect_bark", + ufp_enabled="is_bark_detection_on", + ufp_event_obj="last_bark_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_car_alarm", + name="Car Alarm Detected", + icon="mdi:car", + ufp_value="is_car_alarm_currently_detected", + ufp_required_field="can_detect_car_alarm", + ufp_enabled="is_car_alarm_detection_on", + ufp_event_obj="last_car_alarm_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_car_horn", + name="Car Horn Detected", + icon="mdi:bugle", + ufp_value="is_car_horn_currently_detected", + ufp_required_field="can_detect_car_horn", + ufp_enabled="is_car_horn_detection_on", + ufp_event_obj="last_car_horn_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_glass_break", + name="Glass Break Detected", + icon="mdi:glass-fragile", + ufp_value="last_glass_break_detect", + ufp_required_field="can_detect_glass_break", + ufp_enabled="is_glass_break_detection_on", + ufp_event_obj="last_glass_break_detect_event", + ), ) DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -557,6 +691,16 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): else: self._attr_device_class = self.entity_description.device_class + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_is_on, self._attr_device_class) + class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): """A UniFi Protect NVR Disk Binary Sensor.""" @@ -601,6 +745,16 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): self._attr_is_on = not self._disk.is_healthy + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_is_on) + class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): """A UniFi Protect Device Binary Sensor for events.""" @@ -617,32 +771,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): self._attr_extra_state_attributes = {} @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the is_on, _attr_extra_state_attributes, and available are ever - updated for these entities, and since the websocket update for the - device will trigger an update for all entities connected to the device, - we want to avoid writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_is_on = self._attr_is_on - previous_available = self._attr_available - previous_extra_state_attributes = self._attr_extra_state_attributes - self._async_update_device_from_protect(device) - if ( - self._attr_is_on != previous_is_on - 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() + + return ( + self._attr_available, + self._attr_is_on, + self._attr_extra_state_attributes, + ) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index b69fbb95970..cee4280507d 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -28,7 +28,7 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectButtonEntityDescription( ProtectSetableKeysMixin[T], ButtonEntityDescription ): @@ -193,24 +193,3 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() - - @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. - - Only available is updated for these entities, and since the websocket - update for the device will trigger an update for all entities connected - to the device, we want to avoid writing state unless something has - actually changed. - """ - 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/camera.py b/homeassistant/components/unifiprotect/camera.py index 481d51ec529..6d82e2fc989 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator import logging -from typing import cast +from typing import Any, cast from pyunifiprotect.data import ( Camera as UFPCamera, @@ -181,6 +181,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): else: self._attr_supported_features = CameraEntityFeature(0) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_is_recording, + self._attr_motion_detection_enabled, + ) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) @@ -191,13 +205,11 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._attr_motion_detection_enabled = ( motion_enabled if motion_enabled is not None else True ) + state_type_is_connected = updated_device.state is StateType.CONNECTED self._attr_is_recording = ( - updated_device.state == StateType.CONNECTED and updated_device.is_recording - ) - is_connected = ( - self.data.last_update_success - and updated_device.state == StateType.CONNECTED + state_type_is_connected and updated_device.is_recording ) + is_connected = self.data.last_update_success and state_type_is_connected # some cameras have detachable lens that could cause the camera to be offline self._attr_available = is_connected and updated_device.is_video_ready diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 1ca030ce48e..ec756118eb5 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -295,9 +295,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # validate login data _, errors = await self._async_get_nvr_data(form_data) if not errors: - self.hass.config_entries.async_update_entry(self.entry, data=form_data) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(self.entry, data=form_data) self.context["title_placeholders"] = { "name": self.entry.title, diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 8b8ec80c5ba..11782c42bee 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -192,7 +192,7 @@ class ProtectData: ) -> None: self._async_signal_device_update(device) if ( - device.model == ModelType.CAMERA + device.model is ModelType.CAMERA and device.id in self._pending_camera_ids and "channels" in changed_data ): @@ -249,7 +249,7 @@ class ProtectData: obj.id, ) - if obj.type == EventType.DEVICE_ADOPTED: + if obj.type is EventType.DEVICE_ADOPTED: if obj.metadata is not None and obj.metadata.device_id is not None: device = self.api.bootstrap.get_device_from_id( obj.metadata.device_id diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 28149d349c9..59c716d4aa4 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -80,12 +80,12 @@ def _async_device_entities( can_write = device.can_write(data.api.bootstrap.auth_user) for description in descs: if description.ufp_perm is not None: - if description.ufp_perm == PermRequired.WRITE and not can_write: + if description.ufp_perm is PermRequired.WRITE and not can_write: continue - if description.ufp_perm == PermRequired.NO_WRITE and can_write: + if description.ufp_perm is PermRequired.NO_WRITE and can_write: continue if ( - description.ufp_perm == PermRequired.DELETE + description.ufp_perm is PermRequired.DELETE and not device.can_delete(data.api.bootstrap.auth_user) ): continue @@ -157,17 +157,17 @@ def async_all_device_entities( ) descs = [] - if ufp_device.model == ModelType.CAMERA: + if ufp_device.model is ModelType.CAMERA: descs = camera_descs - elif ufp_device.model == ModelType.LIGHT: + elif ufp_device.model is ModelType.LIGHT: descs = light_descs - elif ufp_device.model == ModelType.SENSOR: + elif ufp_device.model is ModelType.SENSOR: descs = sense_descs - elif ufp_device.model == ModelType.VIEWPORT: + elif ufp_device.model is ModelType.VIEWPORT: descs = viewer_descs - elif ufp_device.model == ModelType.DOORLOCK: + elif ufp_device.model is ModelType.DOORLOCK: descs = lock_descs - elif ufp_device.model == ModelType.CHIME: + elif ufp_device.model is ModelType.CHIME: descs = chime_descs if not descs and not unadopted_descs or ufp_device.model is None: @@ -249,17 +249,43 @@ class ProtectDeviceEntity(Entity): self._attr_available = ( last_update_success and ( - device.state == StateType.CONNECTED + device.state is StateType.CONNECTED or (not device.is_adopted_by_us and device.can_adopt) ) and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) ) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available,) + @callback def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data.""" + """When device is updated from Protect.""" + + previous_attrs = self._async_get_state_attrs() self._async_update_device_from_protect(device) - self.async_write_ha_state() + current_attrs = self._async_get_state_attrs() + if previous_attrs != current_attrs: + if _LOGGER.isEnabledFor(logging.DEBUG): + device_name = device.name + if hasattr(self, "entity_description") and self.entity_description.name: + device_name += f" {self.entity_description.name}" + + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device_name, + device.mac, + previous_attrs, + current_attrs, + ) + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 38ce73828c2..485e715ea39 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -34,7 +34,7 @@ async def async_setup_entry( data: ProtectData = hass.data[DOMAIN][entry.entry_id] async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - if device.model == ModelType.LIGHT and device.can_write( + if device.model is ModelType.LIGHT and device.can_write( data.api.bootstrap.auth_user ): async_add_entities([ProtectLight(data, device)]) @@ -70,6 +70,16 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_brightness) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 791a5e958ea..57ade8ad220 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -70,6 +70,22 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): self._attr_name = f"{self.device.display_name} Lock" + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_is_locked, + self._attr_is_locking, + self._attr_is_unlocking, + self._attr_is_jammed, + ) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) @@ -79,11 +95,11 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): self._attr_is_locking = False self._attr_is_unlocking = False self._attr_is_jammed = False - if lock_status == LockStatusType.CLOSED: + if lock_status is LockStatusType.CLOSED: self._attr_is_locked = True - elif lock_status == LockStatusType.CLOSING: + elif lock_status is LockStatusType.CLOSING: self._attr_is_locking = True - elif lock_status == LockStatusType.OPENING: + elif lock_status is LockStatusType.OPENING: self._attr_is_unlocking = True elif lock_status in ( LockStatusType.FAILED_WHILE_CLOSING, diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index b2376277e6f..e0f247eef72 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -110,41 +110,20 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE is_connected = self.data.last_update_success and ( - updated_device.state == StateType.CONNECTED + updated_device.state is StateType.CONNECTED or (not updated_device.is_adopted_by_us and updated_device.can_adopt) ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the state, volume, and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_state = self._attr_state - previous_available = self._attr_available - previous_volume_level = self._attr_volume_level - self._async_update_device_from_protect(device) - if ( - self._attr_state != previous_state - 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() + + return (self._attr_available, self._attr_state, self._attr_volume_level) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 08f5c2075e6..f7da2f781ff 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import Enum import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel @@ -35,7 +35,7 @@ class PermRequired(int, Enum): DELETE = 3 -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -100,7 +100,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): return bool(get_nested_attr(obj, ufp_required_field)) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" @@ -110,7 +110,8 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Return value from UniFi Protect device.""" if self.ufp_event_obj is not None: - return cast(Event, getattr(obj, self.ufp_event_obj, None)) + event: Event | None = getattr(obj, self.ufp_event_obj, None) + return event return None def get_is_on(self, obj: T, event: Event | None) -> bool: @@ -119,7 +120,7 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return event is not None and self.get_ufp_value(obj) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): """Mixin for settable values.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index c02753a9401..90201da98d8 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from pyunifiprotect.data import ( Camera, @@ -29,22 +30,17 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) -class NumberKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class ProtectNumberEntityDescription( + ProtectSetableKeysMixin[T], NumberEntityDescription +): + """Describes UniFi Protect Number entity.""" ufp_max: int | float ufp_min: int | float ufp_step: int | float -@dataclass(frozen=True) -class ProtectNumberEntityDescription( - ProtectSetableKeysMixin[T], NumberEntityDescription, NumberKeysMixin -): - """Describes UniFi Protect Number entity.""" - - def _get_pir_duration(obj: Light) -> int: return int(obj.light_device_settings.pir_duration.total_seconds()) @@ -273,28 +269,11 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): await self.entity_description.ufp_set(self.device, value) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - 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() + + return (self._attr_available, self._attr_native_value) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index dfc3be2d4a1..eed49ac87e7 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -92,7 +92,7 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSelectEntityDescription( ProtectSetableKeysMixin[T], SelectEntityDescription ): @@ -116,7 +116,7 @@ def _get_doorbell_options(api: ProtectApiClient) -> list[dict[str, Any]]: for item in messages: msg_type = item.type.value - if item.type == DoorbellMessageType.CUSTOM_MESSAGE: + if item.type is DoorbellMessageType.CUSTOM_MESSAGE: msg_type = f"{DoorbellMessageType.CUSTOM_MESSAGE.value}:{item.text}" built_messages.append({"id": msg_type, "name": item.text}) @@ -403,32 +403,11 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): await self.entity_description.ufp_set(self.device, unifi_value) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the options, option, and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_option = self._attr_current_option - previous_options = self._attr_options - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_current_option != previous_option - 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() + + return (self._attr_available, self._attr_options, self._attr_current_option) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index abeb4859e6d..5a52b45b62d 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSensorEntityDescription( ProtectRequiredKeysMixin[T], SensorEntityDescription ): @@ -65,13 +65,12 @@ class ProtectSensorEntityDescription( def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" value = super().get_ufp_value(obj) - - if isinstance(value, float) and self.precision: - value = round(value, self.precision) + if self.precision and value is not None: + return round(value, self.precision) return value -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): @@ -715,31 +714,14 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): self._attr_native_value = self.entity_description.get_ufp_value(self.device) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - 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() + + return (self._attr_available, self._attr_native_value) class ProtectNVRSensor(ProtectNVREntity, SensorEntity): @@ -752,22 +734,14 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity): self._attr_native_value = self.entity_description.get_ufp_value(self.device) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_native_value != previous_value - or self._attr_available != previous_available - ): - self.async_write_ha_state() + + return (self._attr_available, self._attr_native_value) class ProtectEventSensor(EventEntityMixin, SensorEntity): @@ -803,3 +777,17 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): self._attr_native_value = event.metadata.license_plate.name # type: ignore[union-attr] else: self._attr_native_value = event.smart_detect_types[0].value + + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_native_value, + self._attr_extra_state_attributes, + ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 8466ffb6118..ace769d7c43 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -33,7 +33,7 @@ ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSwitchEntityDescription( ProtectSetableKeysMixin[T], SwitchEntityDescription ): @@ -221,6 +221,83 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_cmonx_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="smart_siren", + name="Detections: Siren", + icon="mdi:alarm-bell", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_siren", + ufp_value="is_siren_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_siren_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_baby_cry", + name="Detections: Baby Cry", + icon="mdi:cradle", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_baby_cry", + ufp_value="is_baby_cry_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_baby_cry_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_speak", + name="Detections: Speaking", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_speaking", + ufp_value="is_speaking_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_speaking_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_bark", + name="Detections: Barking", + icon="mdi:dog", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_bark", + ufp_value="is_bark_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_bark_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_car_alarm", + name="Detections: Car Alarm", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_car_alarm", + ufp_value="is_car_alarm_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_car_alarm_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_car_horn", + name="Detections: Car Horn", + icon="mdi:bugle", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_car_horn", + ufp_value="is_car_horn_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_car_horn_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_glass_break", + name="Detections: Glass Break", + icon="mdi:glass-fragile", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_glass_break", + ufp_value="is_glass_break_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_glass_break_detection", + ufp_perm=PermRequired.WRITE, + ), ) PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( @@ -445,31 +522,14 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): await self.entity_description.ufp_set(self.device, False) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the is_on and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_is_on = self._attr_is_on - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - 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() + + return (self._attr_available, self._attr_is_on) class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index de777121ff5..cfc4ad5702f 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from pyunifiprotect.data import ( Camera, @@ -24,7 +25,7 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" @@ -101,6 +102,16 @@ class ProtectDeviceText(ProtectDeviceEntity, TextEntity): super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_native_value) + async def async_set_value(self, value: str) -> None: """Change the value.""" diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 3e2b5e1b19e..f07e1eb9554 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -111,8 +111,8 @@ def async_get_light_motion_current(obj: Light) -> str: """Get light motion mode for Flood Light.""" if ( - obj.light_mode_settings.mode == LightModeType.MOTION - and obj.light_mode_settings.enable_at == LightModeEnableType.DARK + obj.light_mode_settings.mode is LightModeType.MOTION + and obj.light_mode_settings.enable_at is LightModeEnableType.DARK ): return f"{LightModeType.MOTION.value}Dark" return obj.light_mode_settings.mode.value diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index a2554858fef..49ec97f073b 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -57,7 +57,7 @@ STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] -): +): # pylint: disable=hass-enforce-coordinator-module """UpCloud data update coordinator.""" def __init__( diff --git a/homeassistant/components/update/icons.json b/homeassistant/components/update/icons.json new file mode 100644 index 00000000000..96920c96253 --- /dev/null +++ b/homeassistant/components/update/icons.json @@ -0,0 +1,15 @@ +{ + "entity_component": { + "_": { + "default": "mdi:package-up", + "state": { + "off": "mdi:package" + } + } + }, + "services": { + "clear_skipped": "mdi:package", + "install": "mdi:package-down", + "skip": "mdi:package-check" + } +} diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index ffe6d7f5433..4b99611684a 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFF, CONF_TARIFF_ENTITY, @@ -93,6 +94,7 @@ METER_CONFIG_SCHEMA = vol.Schema( cv.ensure_list, vol.Unique(), [cv.string] ), vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern, + vol.Optional(CONF_SENSOR_ALWAYS_AVAILABLE, default=False): cv.boolean, }, period_or_cron, ) diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index eb5c19941dc..0ca9ee12f58 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -23,6 +23,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFFS, DAILY, @@ -68,6 +69,10 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required( CONF_METER_PERIODICALLY_RESETTING, ): selector.BooleanSelector(), + vol.Optional( + CONF_SENSOR_ALWAYS_AVAILABLE, + default=False, + ): selector.BooleanSelector(), } ) @@ -103,6 +108,10 @@ CONFIG_SCHEMA = vol.Schema( CONF_METER_PERIODICALLY_RESETTING, default=True, ): selector.BooleanSelector(), + vol.Optional( + CONF_SENSOR_ALWAYS_AVAILABLE, + default=False, + ): selector.BooleanSelector(), } ) diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index f8a4c2d4b75..4f62925069d 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -1,8 +1,6 @@ """Constants for the utility meter component.""" DOMAIN = "utility_meter" -TARIFF_ICON = "mdi:clock-outline" - QUARTER_HOURLY = "quarter-hourly" HOURLY = "hourly" DAILY = "daily" @@ -38,6 +36,7 @@ CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" CONF_TARIFF_ENTITY = "tariff_entity" CONF_CRON_PATTERN = "cron" +CONF_SENSOR_ALWAYS_AVAILABLE = "always_available" ATTR_TARIFF = "tariff" ATTR_TARIFFS = "tariffs" diff --git a/homeassistant/components/utility_meter/icons.json b/homeassistant/components/utility_meter/icons.json new file mode 100644 index 00000000000..7260fbfbe96 --- /dev/null +++ b/homeassistant/components/utility_meter/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "sensor": { + "utility_meter": { + "default": "mdi:counter" + } + }, + "select": { + "tariff": { + "default": "mdi:clock-outline" + } + } + } +} diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 64b271d4200..86433ca77f8 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -13,13 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ( - CONF_METER, - CONF_SOURCE_SENSOR, - CONF_TARIFFS, - DATA_UTILITY, - TARIFF_ICON, -) +from .const import CONF_METER, CONF_SOURCE_SENSOR, CONF_TARIFFS, DATA_UTILITY _LOGGER = logging.getLogger(__name__) @@ -100,6 +94,8 @@ async def async_setup_platform( class TariffSelect(SelectEntity, RestoreEntity): """Representation of a Tariff selector.""" + _attr_translation_key = "tariff" + def __init__( self, name, @@ -113,7 +109,6 @@ class TariffSelect(SelectEntity, RestoreEntity): self._attr_device_info = device_info self._current_tariff: str | None = None self._tariffs = tariffs - self._attr_icon = TARIFF_ICON self._attr_should_poll = False @property diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 794a65db03a..e9ad7a1ba30 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -58,6 +58,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFF, CONF_TARIFF_ENTITY, @@ -158,6 +159,9 @@ async def async_setup_entry( net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] + sensor_always_available = config_entry.options.get( + CONF_SENSOR_ALWAYS_AVAILABLE, False + ) meters = [] tariffs = config_entry.options[CONF_TARIFFS] @@ -178,6 +182,7 @@ async def async_setup_entry( tariff=None, unique_id=entry_id, device_info=device_info, + sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -198,6 +203,7 @@ async def async_setup_entry( tariff=tariff, unique_id=f"{entry_id}_{tariff}", device_info=device_info, + sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -264,6 +270,9 @@ async def async_setup_platform( CONF_TARIFF_ENTITY ) conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) + conf_sensor_always_available = hass.data[DATA_UTILITY][meter][ + CONF_SENSOR_ALWAYS_AVAILABLE + ] meter_sensor = UtilityMeterSensor( cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, @@ -278,6 +287,7 @@ async def async_setup_platform( tariff=conf_sensor_tariff, unique_id=conf_sensor_unique_id, suggested_entity_id=suggested_entity_id, + sensor_always_available=conf_sensor_always_available, ) meters.append(meter_sensor) @@ -352,7 +362,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" - _attr_icon = "mdi:counter" + _attr_translation_key = "utility_meter" _attr_should_poll = False def __init__( @@ -370,6 +380,7 @@ class UtilityMeterSensor(RestoreSensor): tariff_entity, tariff, unique_id, + sensor_always_available, suggested_entity_id=None, device_info=None, ): @@ -397,6 +408,7 @@ class UtilityMeterSensor(RestoreSensor): _LOGGER.debug("CRON pattern: %s", self._cron_pattern) else: self._cron_pattern = cron_pattern + self._sensor_always_available = sensor_always_available self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption self._sensor_periodically_resetting = periodically_resetting @@ -458,8 +470,9 @@ class UtilityMeterSensor(RestoreSensor): if ( source_state := self.hass.states.get(self._sensor_source_id) ) is None or source_state.state == STATE_UNAVAILABLE: - self._attr_available = False - self.async_write_ha_state() + if not self._sensor_always_available: + self._attr_available = False + self.async_write_ha_state() return self._attr_available = True diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index 918c51cee39..4e8eb23d318 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -4,6 +4,7 @@ reset: target: entity: domain: select + integration: utility_meter calibrate: target: diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index f38989b536e..fc1c727fb0a 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -6,6 +6,7 @@ "title": "Add Utility Meter", "description": "Create a sensor which tracks consumption of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor optionally supports splitting the consumption by tariffs, in that case one sensor for each tariff is created as well as a select entity to choose the current tariff.", "data": { + "always_available": "Sensor always available", "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "[%key:common::config_flow::data::name%]", @@ -16,6 +17,7 @@ "tariffs": "Supported tariffs" }, "data_description": { + "always_available": "If activated, the sensor will always be show the last known value, even if the source entity is unavailable or unknown.", "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", @@ -29,10 +31,12 @@ "step": { "init": { "data": { + "always_available": "[%key:component::utility_meter::config::step::user::data::always_available%]", "source": "[%key:component::utility_meter::config::step::user::data::source%]", "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" }, "data_description": { + "always_available": "[%key:component::utility_meter::config::step::user::data_description::always_available%]", "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 9a10da23824..1bd9719c51c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,13 +1,12 @@ """Support for vacuum cleaner robots (botvacs).""" from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import TYPE_CHECKING, Any, final +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -22,28 +21,18 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API STATE_ON, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import ( - Entity, - EntityDescription, - ToggleEntity, - ToggleEntityDescription, -) +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import ( - async_get_issue_tracker, - async_suggest_report_issue, - bind_hass, -) +from homeassistant.loader import bind_hass if TYPE_CHECKING: from functools import cached_property @@ -131,38 +120,12 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the vacuum component.""" - component = hass.data[DOMAIN] = EntityComponent[_BaseVacuum]( + component = hass.data[DOMAIN] = EntityComponent[StateVacuumEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_ON, - {}, - "async_turn_on", - [VacuumEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, - {}, - "async_turn_off", - [VacuumEntityFeature.TURN_OFF], - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - {}, - "async_toggle", - [VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON], - ) - # start_pause is a legacy service, only supported by VacuumEntity, and only needs - # VacuumEntityFeature.PAUSE - component.async_register_entity_service( - SERVICE_START_PAUSE, - {}, - "async_start_pause", - [VacuumEntityFeature.PAUSE], - ) component.async_register_entity_service( SERVICE_START, {}, @@ -220,30 +183,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = 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[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] return await component.async_unload_entry(entry) -BASE_CACHED_PROPERTIES_WITH_ATTR_ = { +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes vacuum entities.""" + + +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { "supported_features", "battery_level", "battery_icon", "fan_speed", "fan_speed_list", + "state", } -class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): - """Representation of a base vacuum. +class StateVacuumEntity( + Entity, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): + """Representation of a vacuum cleaner robot that supports states.""" - Contains common properties and functions for all vacuum devices. - """ + entity_description: StateVacuumEntityDescription _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) @@ -251,8 +220,60 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): _attr_battery_level: int | None = None _attr_fan_speed: str | None = None _attr_fan_speed_list: list[str] + _attr_state: str | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + @cached_property + def battery_level(self) -> int | None: + """Return the battery level of the vacuum cleaner.""" + return self._attr_battery_level + + @property + def battery_icon(self) -> str: + """Return the battery icon for the vacuum cleaner.""" + charging = bool(self.state == STATE_DOCKED) + + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging + ) + + @property + def capability_attributes(self) -> Mapping[str, Any] | None: + """Return capability attributes.""" + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} + return None + + @cached_property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._attr_fan_speed + + @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 + + @property + 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 VacuumEntityFeature.BATTERY in supported_features: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if VacuumEntityFeature.FAN_SPEED in supported_features: + data[ATTR_FAN_SPEED] = self.fan_speed + + return data + + @cached_property + def state(self) -> str | None: + """Return the state of the vacuum cleaner.""" + return self._attr_state + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" @@ -271,48 +292,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): 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 - - @cached_property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return self._attr_battery_icon - - @cached_property - def fan_speed(self) -> str | None: - """Return the fan speed of the vacuum cleaner.""" - return self._attr_fan_speed - - @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 - - @property - def capability_attributes(self) -> Mapping[str, Any] | None: - """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: - return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} - return None - - @property - 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 VacuumEntityFeature.BATTERY in supported_features: - data[ATTR_BATTERY_LEVEL] = self.battery_level - data[ATTR_BATTERY_ICON] = self.battery_icon - - if VacuumEntityFeature.FAN_SPEED in supported_features: - data[ATTR_FAN_SPEED] = self.fan_speed - - return data - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError() @@ -393,167 +372,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): partial(self.send_command, command, params=params, **kwargs) ) - -class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { - "status", -} - - -class VacuumEntity( - _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ -): - """Representation of a vacuum cleaner robot.""" - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - # Don't report core integrations known to still use the deprecated base class; - # we don't worry about demo and mqtt has it's own deprecation warnings. - if self.platform.platform_name in ("demo", "mqtt"): - return - translation_key = "deprecated_vacuum_base_class" - translation_placeholders = {"platform": self.platform.platform_name} - issue_tracker = async_get_issue_tracker( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - if issue_tracker: - translation_placeholders["issue_tracker"] = issue_tracker - translation_key = "deprecated_vacuum_base_class_url" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_vacuum_base_class_{self.platform.platform_name}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - report_issue = async_suggest_report_issue( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s is extending the deprecated base class VacuumEntity instead of " - "StateVacuumEntity, this is not valid and will be unsupported " - "from Home Assistant 2024.2. Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - report_issue, - ) - - entity_description: VacuumEntityDescription - _attr_status: str | None = None - - @cached_property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._attr_status - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = False - if self.status is not None: - charging = "charg" in self.status.lower() - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - - @final - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the vacuum cleaner.""" - data = super().state_attributes - - if VacuumEntityFeature.STATUS in self.supported_features_compat: - data[ATTR_STATUS] = self.status - - return data - - def turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning.""" - raise NotImplementedError() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_on, **kwargs)) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home.""" - raise NotImplementedError() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_off, **kwargs)) - - def start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - raise NotImplementedError() - - async def async_start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) - - -class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -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 - - @cached_property - def state(self) -> str | None: - """Return the state of the vacuum cleaner.""" - return self._attr_state - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = bool(self.state == STATE_DOCKED) - - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - def start(self) -> None: """Start or resume the cleaning task.""" raise NotImplementedError() diff --git a/homeassistant/components/vacuum/icons.json b/homeassistant/components/vacuum/icons.json new file mode 100644 index 00000000000..25f0cfd03ef --- /dev/null +++ b/homeassistant/components/vacuum/icons.json @@ -0,0 +1,21 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot-vacuum" + } + }, + "services": { + "clean_spot": "mdi:target-variant", + "locate": "mdi:map-marker", + "pause": "mdi:pause", + "return_to_base": "mdi:home-import-outline", + "send_command": "mdi:send", + "set_fan_speed": "mdi:fan", + "start": "mdi:play", + "start_pause": "mdi:play-pause", + "stop": "mdi:stop", + "toggle": "mdi:play-pause", + "turn_off": "mdi:stop", + "turn_on": "mdi:play" + } +} diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 15ba2076060..673c76b7f8d 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -29,16 +29,6 @@ } } }, - "issues": { - "deprecated_vacuum_base_class": { - "title": "The {platform} custom integration is using deprecated vacuum feature", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - }, - "deprecated_vacuum_base_class_url": { - "title": "[%key:component::vacuum::issues::deprecated_vacuum_base_class::title%]", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - } - }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index ce40e07e294..3808bfb1202 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import date import ipaddress import logging -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple from uuid import UUID from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox, ValloxApiException @@ -125,7 +125,7 @@ class ValloxState: @property def model(self) -> str | None: """Return the model, if any.""" - model = cast(str, _api_get_model(self.metric_cache)) + model = _api_get_model(self.metric_cache) if model == "Unknown": return None @@ -155,7 +155,7 @@ class ValloxState: return next_filter_change_date -class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): +class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): # pylint: disable=hass-enforce-coordinator-module """The DataUpdateCoordinator for Vallox.""" diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index c06bc036e4e..b45a2d598c9 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==4.0.2"] + "requirements": ["vallox-websocket-api==4.0.3"] } diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json new file mode 100644 index 00000000000..349196658d4 --- /dev/null +++ b/homeassistant/components/valve/icons.json @@ -0,0 +1,20 @@ +{ + "entity_component": { + "_": { + "default": "mdi:pipe-valve" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "water": { + "default": "mdi:pipe-valve" + } + }, + "services": { + "close_valve": "mdi:valve-closed", + "open_valve": "mdi:valve-open", + "set_valve_position": "mdi:valve", + "stop_valve": "mdi:stop", + "toggle": "mdi:valve-open" + } +} diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ecdddd19289..9afbfc683a8 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -41,6 +41,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_preset_modes = list(PRESET_MODES) + _enable_turn_on_off_backwards_compatibility = False @property def target_temperature(self) -> float | None: diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1f0dd001853..c5f9ccd3563 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.11.0"], + "requirements": ["velbus-aio==2023.12.0"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 1416bcf376a..78cb20b33cc 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -64,7 +64,7 @@ async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: return unload_ok -class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): +class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Venstar data.""" def __init__( diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 6359cc19e57..a9ee56c4dbb 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -108,6 +108,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] _attr_precision = PRECISION_HALVES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -130,6 +131,8 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._client.mode == self._client.MODE_AUTO: diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index f58ae083f72..93d0fbf2aee 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -48,8 +48,12 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): _attr_hvac_modes = SUPPORT_HVAC _attr_fan_modes = FAN_OPERATION_LIST _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__( self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index cadb9b6788d..19a60602540 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -58,7 +58,6 @@ class VerisureDoorWindowSensor( area = self.coordinator.data["door_window"][self.serial_number]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="Shock Sensor Detector", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index a240d45cf7e..e0505328245 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -71,7 +71,6 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="SmartCam", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 1a81b437116..8e57c9695c0 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,6 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="Lockguard Smartlock", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 0fb16aa87c4..51947484dca 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -68,7 +68,6 @@ class VerisureThermometer( area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, @@ -119,7 +118,6 @@ class VerisureHygrometer( area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 427ca5e6ea8..96992cadb75 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -53,7 +53,6 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch ] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="SmartPlug", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 603a42bae41..a2b2f3ac769 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -10,6 +10,7 @@ from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, @@ -85,15 +86,16 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up PyVicare API.""" vicare_api = vicare_login(hass, entry.data) - for device in vicare_api.devices: - _LOGGER.info( + device_config_list = get_supported_devices(vicare_api.devices) + + for device in device_config_list: + _LOGGER.debug( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) # Currently we only support a single device - device_list = vicare_api.devices - device = device_list[0] - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_list + device = device_config_list[0] + hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_config_list hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( device, @@ -113,3 +115,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return unload_ok + + +def get_supported_devices( + devices: list[PyViCareDeviceConfig], +) -> list[PyViCareDeviceConfig]: + """Remove unsupported devices from the list.""" + return [ + device_config + for device_config in devices + if device_config.getModel() not in ["Heatbox1", "Heatbox2_SRC"] + ] diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 2bb0a19924e..ba2665ac083 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -20,7 +20,8 @@ import voluptuous as vol from homeassistant.components.climate import ( PRESET_COMFORT, PRESET_ECO, - PRESET_NONE, + PRESET_HOME, + PRESET_SLEEP, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -85,13 +86,15 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_TO_HA_PRESET_HEATING = { VICARE_PROGRAM_COMFORT: PRESET_COMFORT, VICARE_PROGRAM_ECO: PRESET_ECO, - VICARE_PROGRAM_NORMAL: PRESET_NONE, + VICARE_PROGRAM_NORMAL: PRESET_HOME, + VICARE_PROGRAM_REDUCED: PRESET_SLEEP, } HA_TO_VICARE_PRESET_HEATING = { PRESET_COMFORT: VICARE_PROGRAM_COMFORT, PRESET_ECO: VICARE_PROGRAM_ECO, - PRESET_NONE: VICARE_PROGRAM_NORMAL, + PRESET_HOME: VICARE_PROGRAM_NORMAL, + PRESET_SLEEP: VICARE_PROGRAM_REDUCED, } @@ -142,7 +145,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = VICARE_TEMP_HEATING_MIN @@ -151,6 +157,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) _current_action: bool | None = None _current_mode: str | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 87bfcf7b146..32ae4af0fe7 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -118,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) - _LOGGER.info("Found device with mac %s", formatted_mac) + _LOGGER.debug("Found device with mac %s", formatted_mac) await self.async_set_unique_id(formatted_mac) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 142e3cbabfa..f5a7cfe182a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -210,6 +210,68 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_today", + translation_key="gas_consumption_fuelcell_today", + value_getter=lambda api: api.getFuelCellGasConsumptionToday(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_week", + translation_key="gas_consumption_fuelcell_this_week", + value_getter=lambda api: api.getFuelCellGasConsumptionThisWeek(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_month", + translation_key="gas_consumption_fuelcell_this_month", + value_getter=lambda api: api.getFuelCellGasConsumptionThisMonth(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_year", + translation_key="gas_consumption_fuelcell_this_year", + value_getter=lambda api: api.getFuelCellGasConsumptionThisYear(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_today", + translation_key="gas_consumption_total_today", + value_getter=lambda api: api.getGasConsumptionTotalToday(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_week", + translation_key="gas_consumption_total_this_week", + value_getter=lambda api: api.getGasConsumptionTotalThisWeek(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_month", + translation_key="gas_consumption_total_this_month", + value_getter=lambda api: api.getGasConsumptionTotalThisMonth(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_year", + translation_key="gas_consumption_total_this_year", + value_getter=lambda api: api.getGasConsumptionTotalThisYear(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentday", translation_key="gas_summary_consumption_heating_currentday", @@ -596,41 +658,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _build_entity( - vicare_api, - device_config: PyViCareDeviceConfig, - entity_description: ViCareSensorEntityDescription, -): - """Create a ViCare sensor entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareSensor( - vicare_api, - device_config, - entity_description, - ) - return None - - -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareSensor], - sensor_descriptions: tuple[ViCareSensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - entity = await hass.async_add_executor_job( - _build_entity, - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity: - entities.append(entity) - - def _build_entities( device: PyViCareDevice, device_config: PyViCareDeviceConfig, diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 87b5bb6cc14..96e43be6818 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -140,6 +140,30 @@ "gas_consumption_heating_this_year": { "name": "Heating gas consumption this year" }, + "gas_consumption_fuelcell_today": { + "name": "Fuel cell gas consumption today" + }, + "gas_consumption_fuelcell_this_week": { + "name": "Fuel cell gas consumption this week" + }, + "gas_consumption_fuelcell_this_month": { + "name": "Fuel cell gas consumption this month" + }, + "gas_consumption_fuelcell_this_year": { + "name": "Fuel cell gas consumption this year" + }, + "gas_consumption_total_today": { + "name": "Gas consumption today" + }, + "gas_consumption_total_this_week": { + "name": "Gas consumption this week" + }, + "gas_consumption_total_this_month": { + "name": "Gas consumption this month" + }, + "gas_consumption_total_this_year": { + "name": "Gas consumption this year" + }, "gas_summary_consumption_heating_currentday": { "name": "Heating gas consumption today" }, diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 0f5b3bc967c..2e468087725 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_APPS not in hass.data[DOMAIN] and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): - store: Store = Store(hass, 1, DOMAIN) + store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) coordinator = VizioAppsDataUpdateCoordinator(hass, store) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator @@ -97,10 +97,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): +class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant, store: Store) -> None: + def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: """Initialize.""" super().__init__( hass, diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 014cd3cab0f..792407d2545 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -188,8 +188,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize config flow.""" self._user_schema = None self._must_show_form: bool | None = None - self._ch_type = None - self._pairing_token = None + self._ch_type: str | None = None + self._pairing_token: str | None = None self._data: dict[str, Any] | None = None self._apps: dict[str, list] = {} @@ -208,7 +208,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: # Store current values in case setup fails and user needs to edit @@ -294,8 +294,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if await self.hass.async_add_executor_job( _host_is_same, entry.data[CONF_HOST], import_config[CONF_HOST] ): - updated_options = {} - updated_data = {} + updated_options: dict[str, Any] = {} + updated_data: dict[str, Any] = {} remove_apps = False if entry.data[CONF_HOST] != import_config[CONF_HOST]: @@ -393,10 +393,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Ask user for PIN to complete pairing process. """ errors: dict[str, str] = {} + assert self._data # Start pairing process if it hasn't already started if not self._ch_type and not self._pairing_token: - assert self._data dev = VizioAsync( DEVICE_ID, self._data[CONF_HOST], diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 057fd33e8dc..e3de3caa99d 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from pyvizio import VizioAsync +from pyvizio import AppConfig, VizioAsync from pyvizio.api.apps import find_app_name from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP @@ -144,9 +144,8 @@ class VizioDevice(MediaPlayerEntity): self._apps_coordinator = apps_coordinator self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._current_input = None - self._current_app_config = None - self._attr_app_name = None + self._current_input: str | None = None + self._current_app_config: AppConfig | None = None self._available_inputs: list[str] = [] self._available_apps: list[str] = [] self._all_apps = apps_coordinator.data if apps_coordinator else None @@ -377,7 +376,7 @@ class VizioDevice(MediaPlayerEntity): return self._available_inputs @property - def app_id(self) -> str | None: + def app_id(self): """Return the ID of the current app if it is unknown by pyvizio.""" if self._current_app_config and self.source == UNKNOWN_APP: return { @@ -388,9 +387,9 @@ class VizioDevice(MediaPlayerEntity): return None - async def async_select_sound_mode(self, sound_mode): + async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" - if sound_mode in self._attr_sound_mode_list: + if sound_mode in (self._attr_sound_mode_list or ()): await self._device.set_setting( VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 816e9241739..b4c44ea9130 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.BUTTON] +PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -24,6 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.async_on_unload(entry.add_update_listener(update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -38,3 +40,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update when config_entry options update.""" + if entry.options: + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index dc33d0db52b..987d4d71f41 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -7,9 +7,18 @@ from typing import Any from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions import voluptuous as vol -from homeassistant import core -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -30,9 +39,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) -async def validate_input( - hass: core.HomeAssistant, data: dict[str, Any] -) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" api = VodafoneStationSercommApi( @@ -54,6 +61,12 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return VodafoneStationOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -133,3 +146,27 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors, ) + + +class VodafoneStationOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle a option flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index fab266ac47f..8910d7178b7 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -36,6 +36,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } + } + } + }, "entity": { "button": { "dsl_reconnect": { "name": "DSL reconnect" }, diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index 94a3aacc0fd..f145f866ae3 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -51,7 +51,7 @@ class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): def __init__(self, hass: HomeAssistant, device: VoIPDevice) -> None: """Initialize a pipeline selector.""" VoIPEntity.__init__(self, device) - AssistPipelineSelect.__init__(self, hass, device.voip_id) + AssistPipelineSelect.__init__(self, hass, DOMAIN, device.voip_id) class VoipVadSensitivitySelect(VoIPEntity, VadSensitivitySelect): diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 4ec1bf4a4ba..8bade56fa97 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -168,7 +168,7 @@ class VolvoData: raise InvalidAuth from exc -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): +class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Volvo coordinator.""" def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index b52b4181510..0bfd09d590d 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,32 +1,21 @@ """The Vulcan component.""" -import sys from aiohttp import ClientConnectorError +from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -if sys.version_info < (3, 12): - from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan - PLATFORMS = [Platform.CALENDAR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uonet+ Vulcan integration.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Uonet+ Vulcan is not supported on Python 3.12. Please use Python 3.11." - ) hass.data.setdefault(DOMAIN, {}) try: keystore = Keystore.load(entry.data["keystore"]) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index fea87480cf0..47ab7ec53cb 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["vulcan-api==2.3.0"] + "requirements": ["vulcan-api==2.3.2"] } diff --git a/homeassistant/components/wake_word/icons.json b/homeassistant/components/wake_word/icons.json new file mode 100644 index 00000000000..c1deaeba5fb --- /dev/null +++ b/homeassistant/components/wake_word/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:chat-sleep" + } + } +} diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 8194a3ea262..4ca6e768f64 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator -PLATFORMS = [Platform.SENSOR, Platform.NUMBER, Platform.LOCK, Platform.SWITCH] +PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index d3cf1af21a2..d4e41095b26 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -47,7 +47,7 @@ async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: entity_registry, entry.entry_id ) for reg_entry in registry_entries: - if isinstance(reg_entry.unique_id, int): - entity_registry.async_update_entity( + if isinstance(reg_entry.unique_id, int): # type: ignore[unreachable] + entity_registry.async_update_entity( # type: ignore[unreachable] reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" ) diff --git a/homeassistant/components/water_heater/icons.json b/homeassistant/components/water_heater/icons.json new file mode 100644 index 00000000000..af6996374c5 --- /dev/null +++ b/homeassistant/components/water_heater/icons.json @@ -0,0 +1,31 @@ +{ + "entity_component": { + "_": { + "default": "mdi:water-boiler", + "state": { + "off": "mdi:water-boiler-off" + }, + "state_attributes": { + "operation_mode": { + "default": "mdi:circle-medium", + "state": { + "eco": "mdi:leaf", + "electric": "mdi:lightning-bolt", + "gas": "mdi:fire-circle", + "heat_pump": "mdi:heat-wave", + "high_demand": "mdi:finance", + "off": "mdi:power", + "performance": "mdi:rocket-launch" + } + } + } + } + }, + "services": { + "set_away_mode": "mdi:account-arrow-right", + "set_operation_mode": "mdi:water-boiler", + "set_temperature": "mdi:thermometer", + "turn_off": "mdi:water-boiler-off", + "turn_on": "mdi:water-boiler" + } +} diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b54d723f95d..ef372e5fd33 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -110,7 +110,7 @@ class WazeTravelTime(SensorEntity): async def async_added_to_hass(self) -> None: """Handle when entity is added.""" - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.first_update ) diff --git a/homeassistant/components/weather/icons.json b/homeassistant/components/weather/icons.json new file mode 100644 index 00000000000..cc53861e700 --- /dev/null +++ b/homeassistant/components/weather/icons.json @@ -0,0 +1,27 @@ +{ + "entity_component": { + "_": { + "default": "mdi:weather-partly-cloudy", + "state": { + "clear-night": "mdi:weather-night", + "cloudy": "mdi:weather-cloudy", + "exceptional": "mdi:alert-circle-outline", + "fog": "mdi:weather-fog", + "hail": "mdi:weather-hail", + "lightning": "mdi:weather-lightning", + "lightning-rainy": "mdi:weather-lightning-rainy", + "pouring": "mdi:weather-pouring", + "rainy": "mdi:weather-rainy", + "snowy": "mdi:weather-snowy", + "snowy-rainy": "mdi:weather-snowy-rainy", + "sunny": "mdi:weather-sunny", + "windy": "mdi:weather-windy", + "windy-variant": "mdi:weather-windy-variant" + } + } + }, + "services": { + "get_forecast": "mdi:weather-cloudy-clock", + "get_forecasts": "mdi:weather-cloudy-clock" + } +} diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 0b712a4de05..8879bf158f3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -18,7 +18,7 @@ "snowy-rainy": "Snowy, rainy", "sunny": "Sunny", "windy": "Windy", - "windy-variant": "Windy" + "windy-variant": "Windy, cloudy" }, "state_attributes": { "forecast": { diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 16f3e5c7ef2..00b27fdb647 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -174,7 +174,7 @@ async def async_handle_webhook( ) try: - response = await webhook["handler"](hass, webhook_id, request) + response: Response | None = await webhook["handler"](hass, webhook_id, request) if response is None: response = Response(status=HTTPStatus.OK) return response diff --git a/homeassistant/components/webhook/strings.json b/homeassistant/components/webhook/strings.json deleted file mode 100644 index 53b932727d0..00000000000 --- a/homeassistant/components/webhook/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "trigger_missing_local_only": { - "title": "Update webhook trigger: {webhook_id}", - "description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. [Edit the automation]({edit}) \"{automation_name}\", (`{entity_id}`) and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'" - } - } -} diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 78728793f5d..05bb53564bd 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -3,18 +3,14 @@ from __future__ import annotations from dataclasses import dataclass import logging +from typing import Any -from aiohttp import hdrs +from aiohttp import hdrs, web import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -57,9 +53,11 @@ class TriggerInstance: job: HassJob -async def _handle_webhook(hass, webhook_id, request): +async def _handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request +) -> None: """Handle incoming webhook.""" - base_result = {"platform": "webhook", "webhook_id": webhook_id} + base_result: dict[str, Any] = {"platform": "webhook", "webhook_id": webhook_id} if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): base_result["json"] = await request.json() @@ -85,31 +83,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Trigger based on incoming webhooks.""" webhook_id: str = config[CONF_WEBHOOK_ID] - local_only = config.get(CONF_LOCAL_ONLY) - issue_id: str | None = None - if local_only is None: - issue_id = f"trigger_missing_local_only_{webhook_id}" - variables = trigger_info["variables"] or {} - automation_info = variables.get("this", {}) - automation_id = automation_info.get("attributes", {}).get("id") - automation_entity_id = automation_info.get("entity_id") - automation_name = trigger_info.get("name") or automation_entity_id - async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2023.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger", - translation_key="trigger_missing_local_only", - translation_placeholders={ - "webhook_id": webhook_id, - "automation_name": automation_name, - "entity_id": automation_entity_id, - "edit": f"/config/automation/edit/{automation_id}", - }, - ) + local_only = config.get(CONF_LOCAL_ONLY, True) allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS) job = HassJob(action) @@ -133,10 +107,8 @@ async def async_attach_trigger( triggers[webhook_id].append(trigger_instance) @callback - def unregister(): + def unregister() -> None: """Unregister webhook.""" - if issue_id: - async_delete_issue(hass, DOMAIN, issue_id) triggers[webhook_id].remove(trigger_instance) if not triggers[webhook_id]: async_unregister(hass, webhook_id) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index f12b1c08c60..554d5e0b1d6 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -8,7 +8,6 @@ from datetime import timedelta from functools import wraps from http import HTTPStatus import logging -import ssl from typing import Any, Concatenate, ParamSpec, TypeVar, cast from aiowebostv import WebOsClient, WebOsTvPairError @@ -473,14 +472,11 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): SSLContext to bypass validation errors if url starts with https. """ content = None - ssl_context = None - if url.startswith("https"): - ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) websession = async_get_clientsession(self.hass) with suppress(asyncio.TimeoutError): async with asyncio.timeout(10): - response = await websession.get(url, ssl=ssl_context) + response = await websession.get(url, ssl=False) if response.status == HTTPStatus.OK: content = await response.read() diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 2c86a26efc9..3940e1333d0 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -1,17 +1,17 @@ """Handle the auth of a connection.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Final from aiohttp.web import Request import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.auth.models import RefreshToken, User from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.const import __version__ from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.json import json_bytes from homeassistant.util.json import JsonValueType from .connection import ActiveConnection @@ -34,20 +34,15 @@ AUTH_MESSAGE_SCHEMA: Final = vol.Schema( } ) - -def auth_ok_message() -> dict[str, str]: - """Return an auth_ok message.""" - return {"type": TYPE_AUTH_OK, "ha_version": __version__} +AUTH_OK_MESSAGE = json_bytes({"type": TYPE_AUTH_OK, "ha_version": __version__}) +AUTH_REQUIRED_MESSAGE = json_bytes( + {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} +) -def auth_required_message() -> dict[str, str]: - """Return an auth_required message.""" - return {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} - - -def auth_invalid_message(message: str) -> dict[str, str]: +def auth_invalid_message(message: str) -> bytes: """Return an auth_invalid message.""" - return {"type": TYPE_AUTH_INVALID, "message": message} + return json_bytes({"type": TYPE_AUTH_INVALID, "message": message}) class AuthPhase: @@ -57,16 +52,20 @@ class AuthPhase: self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], cancel_ws: CALLBACK_TYPE, request: Request, + send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]], ) -> None: - """Initialize the authentiated connection.""" + """Initialize the authenticated connection.""" self._hass = hass + # send_message will send a message to the client via the queue. self._send_message = send_message self._cancel_ws = cancel_ws self._logger = logger self._request = request + # send_bytes_text will directly send a message to the client. + self._send_bytes_text = send_bytes_text async def async_handle(self, msg: JsonValueType) -> ActiveConnection: """Handle authentication.""" @@ -77,34 +76,31 @@ class AuthPhase: f"Auth message incorrectly formatted: {humanize_error(msg, err)}" ) self._logger.warning(error_msg) - self._send_message(auth_invalid_message(error_msg)) + await self._send_bytes_text(auth_invalid_message(error_msg)) raise Disconnect from err if (access_token := valid_msg.get("access_token")) and ( - refresh_token := await self._hass.auth.async_validate_access_token( - access_token - ) + refresh_token := self._hass.auth.async_validate_access_token(access_token) ): - conn = await self._async_finish_auth(refresh_token.user, refresh_token) + conn = ActiveConnection( + self._logger, + self._hass, + self._send_message, + refresh_token.user, + refresh_token, + ) conn.subscriptions[ "auth" ] = self._hass.auth.async_register_revoke_token_callback( refresh_token.id, self._cancel_ws ) - + await self._send_bytes_text(AUTH_OK_MESSAGE) + self._logger.debug("Auth OK") + process_success_login(self._request) return conn - self._send_message(auth_invalid_message("Invalid access token or password")) + await self._send_bytes_text( + auth_invalid_message("Invalid access token or password") + ) await process_wrong_login(self._request) raise Disconnect - - async def _async_finish_auth( - self, user: User, refresh_token: RefreshToken - ) -> ActiveConnection: - """Create an active connection.""" - self._logger.debug("Auth OK") - process_success_login(self._request) - self._send_message(auth_ok_message()) - return ActiveConnection( - self._logger, self._hass, self._send_message, user, refresh_token - ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index dfd04aa001a..c088acc6e00 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -45,7 +45,7 @@ from homeassistant.helpers.json import ( JSON_DUMP, ExtendedJSONEncoder, find_paths_unserializable_data, - json_dumps, + json_bytes, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import EventType @@ -104,7 +104,7 @@ def pong_message(iden: int) -> dict[str, Any]: @callback def _forward_events_check_permissions( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], user: User, msg_id: int, event: Event, @@ -113,16 +113,18 @@ def _forward_events_check_permissions( # We have to lookup the permissions again because the user might have # changed since the subscription was created. permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + if ( + not user.is_admin + and not permissions.access_all_entities(POLICY_READ) + and not permissions.check_entity(event.data["entity_id"], POLICY_READ) + ): return send_message(messages.cached_event_message(msg_id, event)) @callback def _forward_events_unconditional( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], msg_id: int, event: Event, ) -> None: @@ -306,7 +308,8 @@ async def handle_call_service( def _async_get_allowed_states( hass: HomeAssistant, connection: ActiveConnection ) -> list[State]: - if connection.user.permissions.access_all_entities(POLICY_READ): + user = connection.user + if user.is_admin or user.permissions.access_all_entities(POLICY_READ): return hass.states.async_all() entity_perm = connection.user.permissions.check_entity return [ @@ -349,17 +352,17 @@ def handle_get_states( def _send_handle_get_states_response( - connection: ActiveConnection, msg_id: int, serialized_states: list[str] + connection: ActiveConnection, msg_id: int, serialized_states: list[bytes] ) -> None: """Send handle get states response.""" connection.send_message( - construct_result_message(msg_id, f'[{",".join(serialized_states)}]') + construct_result_message(msg_id, b"[" + b",".join(serialized_states) + b"]") ) @callback def _forward_entity_changes( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[str | bytes | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], user: User, msg_id: int, @@ -372,9 +375,11 @@ def _forward_entity_changes( # We have to lookup the permissions again because the user might have # changed since the subscription was created. permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + if ( + not user.is_admin + and not permissions.access_all_entities(POLICY_READ) + and not permissions.check_entity(event.data["entity_id"], POLICY_READ) + ): return send_message(messages.cached_state_diff_message(msg_id, event)) @@ -439,15 +444,23 @@ def handle_subscribe_entities( def _send_handle_entities_init_response( - connection: ActiveConnection, msg_id: int, serialized_states: list[str] + connection: ActiveConnection, msg_id: int, serialized_states: list[bytes] ) -> None: """Send handle entities init response.""" connection.send_message( - f'{{"id":{msg_id},"type":"event","event":{{"a":{{{",".join(serialized_states)}}}}}}}' + b"".join( + ( + b'{"id":', + str(msg_id).encode(), + b',"type":"event","event":{"a":{', + b",".join(serialized_states), + b"}}}", + ) + ) ) -async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: +async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" descriptions = await async_get_all_descriptions(hass) if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: @@ -456,8 +469,8 @@ async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: ] # If the descriptions are the same, return the cached JSON payload if cached_descriptions is descriptions: - return cast(str, cached_json_payload) - json_payload = json_dumps(descriptions) + return cast(bytes, cached_json_payload) + json_payload = json_bytes(descriptions) hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) return json_payload diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 25b6c90d1ba..e4540dfac35 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -51,7 +51,7 @@ class ActiveConnection: self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, refresh_token: RefreshToken, ) -> None: @@ -244,7 +244,7 @@ class ActiveConnection: @callback def _connect_closed_error( - self, msg: str | dict[str, Any] | Callable[[], str] + self, msg: bytes | str | dict[str, Any] | Callable[[], str] ) -> None: """Send a message when the connection is closed.""" self.logger.debug("Tried to send message %s on closed connection", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index f2f667368c3..416573d493c 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -3,8 +3,9 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Callable +from collections.abc import Callable, Coroutine import datetime as dt +from functools import partial import logging from typing import TYPE_CHECKING, Any, Final @@ -17,7 +18,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.json import json_loads -from .auth import AuthPhase, auth_required_message +from .auth import AUTH_REQUIRED_MESSAGE, AuthPhase from .const import ( DATA_CONNECTIONS, MAX_PENDING_MSG, @@ -28,7 +29,7 @@ from .const import ( URL, ) from .error import Disconnect -from .messages import message_to_json +from .messages import message_to_json_bytes from .util import describe_request if TYPE_CHECKING: @@ -94,7 +95,7 @@ class WebSocketHandler: # to where messages are queued. This allows the implementation # to use a deque and an asyncio.Future to avoid the overhead of # an asyncio.Queue. - self._message_queue: deque[str | None] = deque() + self._message_queue: deque[bytes | None] = deque() self._ready_future: asyncio.Future[None] | None = None def __repr__(self) -> str: @@ -115,13 +116,14 @@ class WebSocketHandler: return describe_request(request) return "finished connection" - async def _writer(self) -> None: + async def _writer( + self, send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]] + ) -> None: """Write outgoing messages.""" # Variables are set locally to avoid lookups in the loop message_queue = self._message_queue logger = self._logger wsock = self._wsock - send_str = wsock.send_str loop = self._hass.loop debug = logger.debug is_enabled_for = logger.isEnabledFor @@ -148,10 +150,10 @@ class WebSocketHandler: ): if debug_enabled: debug("%s: Sending %s", self.description, message) - await send_str(message) + await send_bytes_text(message) continue - messages: list[str] = [message] + messages: list[bytes] = [message] while messages_remaining: # A None message is used to signal the end of the connection if (message := message_queue.popleft()) is None: @@ -159,10 +161,10 @@ class WebSocketHandler: messages.append(message) messages_remaining -= 1 - coalesced_messages = f'[{",".join(messages)}]' + coalesced_messages = b"".join((b"[", b",".join(messages), b"]")) if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) - await send_str(coalesced_messages) + await send_bytes_text(coalesced_messages) except asyncio.CancelledError: debug("%s: Writer cancelled", self.description) raise @@ -181,8 +183,8 @@ class WebSocketHandler: self._peak_checker_unsub = None @callback - def _send_message(self, message: str | dict[str, Any]) -> None: - """Send a message to the client. + def _send_message(self, message: str | bytes | dict[str, Any]) -> None: + """Queue sending a message to the client. Closes connection if the client is not reading the messages. @@ -194,7 +196,9 @@ class WebSocketHandler: return if isinstance(message, dict): - message = message_to_json(message) + message = message_to_json_bytes(message) + elif isinstance(message, str): + message = message.encode("utf-8") message_queue = self._message_queue queue_size_before_add = len(message_queue) @@ -260,6 +264,11 @@ class WebSocketHandler: if self._writer_task is not None: self._writer_task.cancel() + @callback + def _async_handle_hass_stop(self, event: Event) -> None: + """Cancel this connection.""" + self._cancel() + async def async_handle(self) -> web.WebSocketResponse: """Handle a websocket response.""" request = self._request @@ -280,28 +289,27 @@ class WebSocketHandler: debug("%s: Connected from %s", self.description, request.remote) self._handle_task = asyncio.current_task() - @callback - def handle_hass_stop(event: Event) -> None: - """Cancel this connection.""" - self._cancel() + unsub_stop = hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop + ) - unsub_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_hass_stop) + writer = wsock._writer # pylint: disable=protected-access + if TYPE_CHECKING: + assert writer is not None - # As the webserver is now started before the start - # event we do not want to block for websocket responses - self._writer_task = asyncio.create_task(self._writer()) - - auth = AuthPhase(logger, hass, self._send_message, self._cancel, request) + send_bytes_text = partial(writer.send, binary=False) + auth = AuthPhase( + logger, hass, self._send_message, self._cancel, request, send_bytes_text + ) connection = None disconnect_warn = None try: - self._send_message(auth_required_message()) + await send_bytes_text(AUTH_REQUIRED_MESSAGE) # Auth Phase try: - async with asyncio.timeout(10): - msg = await wsock.receive() + msg = await wsock.receive(10) except asyncio.TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" raise Disconnect from err @@ -322,7 +330,13 @@ class WebSocketHandler: if is_enabled_for(logging_debug): debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) + # As the webserver is now started before the start + # event we do not want to block for websocket responses + # + # We only start the writer queue after the auth phase is completed + # since there is no need to queue messages before the auth phase self._connection = connection + self._writer_task = asyncio.create_task(self._writer(send_bytes_text)) hass.data[DATA_CONNECTIONS] = hass.data.get(DATA_CONNECTIONS, 0) + 1 async_dispatcher_send(hass, SIGNAL_WEBSOCKET_CONNECTED) @@ -362,7 +376,7 @@ class WebSocketHandler: # added a way to set the limit, but there is no way to actually # reach the code to set the limit, so we have to set it directly. # - wsock._writer._limit = 2**20 # type: ignore[union-attr] # pylint: disable=protected-access + writer._limit = 2**20 # pylint: disable=protected-access async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary @@ -373,7 +387,7 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break - if msg.type == WSMsgType.BINARY: + if msg.type is WSMsgType.BINARY: if len(msg.data) < 1: disconnect_warn = "Received invalid binary message." break @@ -382,7 +396,7 @@ class WebSocketHandler: async_handle_binary(handler, payload) continue - if msg.type != WSMsgType.TEXT: + if msg.type is not WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break @@ -395,7 +409,8 @@ class WebSocketHandler: if is_enabled_for(logging_debug): debug("%s: Received %s", self.description, command_msg_data) - if not isinstance(command_msg_data, list): + # command_msg_data is always deserialized from JSON as a list + if type(command_msg_data) is not list: # noqa: E721 async_handle_str(command_msg_data) continue @@ -432,7 +447,8 @@ class WebSocketHandler: # so we have another finally block to make sure we close the websocket # if the writer gets canceled. try: - await self._writer_task + if self._writer_task: + await self._writer_task finally: try: # Make sure all error messages are written before closing diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 34ca6886b5e..3916cdd3af7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -16,7 +16,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data +from homeassistant.helpers.json import ( + JSON_DUMP, + find_paths_unserializable_data, + json_bytes, +) from homeassistant.util.json import format_unserializable_data from . import const @@ -44,7 +48,7 @@ BASE_ERROR_MESSAGE = { "success": False, } -INVALID_JSON_PARTIAL_MESSAGE = JSON_DUMP( +INVALID_JSON_PARTIAL_MESSAGE = json_bytes( { **BASE_ERROR_MESSAGE, "error": { @@ -60,9 +64,17 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} -def construct_result_message(iden: int, payload: str) -> str: +def construct_result_message(iden: int, payload: bytes) -> bytes: """Construct a success result message JSON.""" - return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' + return b"".join( + ( + b'{"id":', + str(iden).encode(), + b',"type":"result","success":true,"result":', + payload, + b"}", + ) + ) def error_message( @@ -96,7 +108,7 @@ def event_message(iden: int, event: Any) -> dict[str, Any]: return {"id": iden, "type": "event", "event": event} -def cached_event_message(iden: int, event: Event) -> str: +def cached_event_message(iden: int, event: Event) -> bytes: """Return an event message. Serialize to json once per message. @@ -105,23 +117,30 @@ def cached_event_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return f'{_partial_cached_event_message(event)[:-1]},"id":{iden}}}' + return b"".join( + ( + _partial_cached_event_message(event)[:-1], + b',"id":', + str(iden).encode(), + b"}", + ) + ) @lru_cache(maxsize=128) -def _partial_cached_event_message(event: Event) -> str: +def _partial_cached_event_message(event: Event) -> bytes: """Cache and serialize the event to json. The message is constructed without the id which appended in cached_event_message. """ return ( - _message_to_json_or_none({"type": "event", "event": event.as_dict()}) + _message_to_json_bytes_or_none({"type": "event", "event": event.json_fragment}) or INVALID_JSON_PARTIAL_MESSAGE ) -def cached_state_diff_message(iden: int, event: Event) -> str: +def cached_state_diff_message(iden: int, event: Event) -> bytes: """Return an event message. Serialize to json once per message. @@ -130,18 +149,27 @@ def cached_state_diff_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return f'{_partial_cached_state_diff_message(event)[:-1]},"id":{iden}}}' + return b"".join( + ( + _partial_cached_state_diff_message(event)[:-1], + b',"id":', + str(iden).encode(), + b"}", + ) + ) @lru_cache(maxsize=128) -def _partial_cached_state_diff_message(event: Event) -> str: +def _partial_cached_state_diff_message(event: Event) -> bytes: """Cache and serialize the event to json. The message is constructed without the id which will be appended in cached_state_diff_message """ return ( - _message_to_json_or_none({"type": "event", "event": _state_diff_event(event)}) + _message_to_json_bytes_or_none( + {"type": "event", "event": _state_diff_event(event)} + ) or INVALID_JSON_PARTIAL_MESSAGE ) @@ -183,9 +211,9 @@ def _state_diff( if old_state.state != new_state.state: additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: - additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp() + additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp elif old_state.last_updated != new_state.last_updated: - additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp() + additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp if old_state_context.parent_id != new_state_context.parent_id: additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} if old_state_context.user_id != new_state_context.user_id: @@ -204,7 +232,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 @@ -212,10 +240,10 @@ def _state_diff( return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} -def _message_to_json_or_none(message: dict[str, Any]) -> str | None: +def _message_to_json_bytes_or_none(message: dict[str, Any]) -> bytes | None: """Serialize a websocket message to json or return None.""" try: - return JSON_DUMP(message) + return json_bytes(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", @@ -226,9 +254,9 @@ def _message_to_json_or_none(message: dict[str, Any]) -> str | None: return None -def message_to_json(message: dict[str, Any]) -> str: +def message_to_json_bytes(message: dict[str, Any]) -> bytes: """Serialize a websocket message to json or return an error.""" - return _message_to_json_or_none(message) or JSON_DUMP( + return _message_to_json_bytes_or_none(message) or json_bytes( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3f7cbe4cf45..2f4d4c84c5c 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType @@ -252,6 +252,7 @@ class WemoDiscovery: self._stop: CALLBACK_TYPE | None = None self._scan_delay = 0 self._static_config = static_config + self._discover_job: HassJob[[datetime], Coroutine[Any, Any, None]] | None = None async def async_discover_and_schedule( self, event_time: datetime | None = None @@ -271,10 +272,12 @@ class WemoDiscovery: self._scan_delay + self.ADDITIONAL_SECONDS_BETWEEN_SCANS, self.MAX_SECONDS_BETWEEN_SCANS, ) + if not self._discover_job: + self._discover_job = HassJob(self.async_discover_and_schedule) self._stop = async_call_later( self._hass, self._scan_delay, - self.async_discover_and_schedule, + self._discover_job, ) @callback diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index c0428e62b71..71a1eac62a8 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pywemo"], - "requirements": ["pywemo==1.3.0"], + "requirements": ["pywemo==1.4.0"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 110943a6503..2c216100244 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -85,7 +85,7 @@ class Options: ) -class DeviceCoordinator(DataUpdateCoordinator[None]): +class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Home Assistant wrapper for a pyWeMo device.""" options: Options | None = None diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 2d38d713859..48b9b99c1e2 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -103,10 +103,13 @@ class AirConEntity(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_swing_modes = SUPPORTED_SWING_MODES _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 1317befcf3f..12583ba4758 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -47,7 +47,6 @@ async def async_setup_entry( class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" - _attr_icon = "mdi:bed" _attr_translation_key = "in_bed" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY coordinator: WithingsBedPresenceDataUpdateCoordinator diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json new file mode 100644 index 00000000000..f76761ce953 --- /dev/null +++ b/homeassistant/components/withings/icons.json @@ -0,0 +1,124 @@ +{ + "entity": { + "binary_sensor": { + "in_bed": { + "default": "mdi:bed-outline", + "state": { + "on": "mdi:bed", + "off": "mdi:bed-empty" + } + } + }, + "sensor": { + "bone_mass": { + "default": "mdi:bone" + }, + "heart_pulse": { + "default": "mdi:heart-pulse" + }, + "hydration": { + "default": "mdi:water" + }, + "deep_sleep": { + "default": "mdi:sleep" + }, + "time_to_sleep": { + "default": "mdi:sleep" + }, + "time_to_wakeup": { + "default": "mdi:sleep-off" + }, + "average_heart_rate": { + "default": "mdi:heart-pulse" + }, + "maximum_heart_rate": { + "default": "mdi:heart-pulse" + }, + "minimum_heart_rate": { + "default": "mdi:heart-pulse" + }, + "light_sleep": { + "default": "mdi:sleep" + }, + "rem_sleep": { + "default": "mdi:sleep" + }, + "sleep_score": { + "default": "mdi:medal" + }, + "wakeup_count": { + "default": "mdi:sleep-off" + }, + "wakeup_time": { + "default": "mdi:sleep-off" + }, + "activity_steps_today": { + "default": "mdi:shoe-print" + }, + "activity_distance_today": { + "default": "mdi:map-marker-distance" + }, + "activity_elevation_today": { + "default": "mdi:stairs-up" + }, + "step_goal": { + "default": "mdi:shoe-print" + }, + "sleep_goal": { + "default": "mdi:bed-clock" + }, + "workout_distance": { + "default": "mdi:map-marker-distance" + }, + "workout_type": { + "state": { + "walk": "mdi:walk", + "run": "mdi:run", + "hiking": "mdi:hiking", + "skating": "mdi:skateboarding", + "bicycling": "mdi:bike", + "swimming": "mdi:swim", + "surfing": "mdi:surfing", + "kitesurfing": "mdi:kitesurfing", + "windsurfing": "mdi:kitesurfing", + "tennis": "mdi:tennis", + "table_tennis": "mdi:table-tennis", + "squash": "mdi:racquetball", + "badminton": "mdi:badminton", + "lift_weights": "mdi:weight-lifter", + "basket_ball": "mdi:basketball", + "soccer": "mdi:soccer", + "football": "mdi:football", + "rugby": "mdi:rugby", + "volley_ball": "mdi:volleyball", + "waterpolo": "mdi:water-polo", + "horse_riding": "mdi:horse-human", + "golf": "mdi:golf", + "yoga": "mdi:yoga", + "dancing": "mdi:human-female-dance", + "boxing": "mdi:boxing-glove", + "fencing": "mdi:fencing", + "martial_arts": "mdi:karate", + "skiing": "mdi:ski", + "snowboarding": "mdi:snowboard", + "rowing": "mdi:rowing", + "baseball": "mdi:baseball", + "handball": "mdi:handball", + "hockey": "mdi:hockey-sticks", + "ice_hockey": "mdi:hockey-sticks", + "climbing": "mdi:carabiner", + "ice_skating": "mdi:skate" + } + }, + "workout_elevation": { + "default": "mdi:stairs-up" + }, + "workout_pause_duration": { + "default": "mdi:timer-pause" + }, + "workout_duration": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index fe5704d119c..36e34ffc187 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==2.0.0"] + "requirements": ["aiowithings==2.1.0"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index de053d6a894..d882cd8cddd 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -107,7 +107,6 @@ MEASUREMENT_SENSORS: dict[ key="bone_mass_kg", measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", - icon="mdi:bone", native_unit_of_measurement=UnitOfMass.KILOGRAMS, suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, @@ -173,7 +172,6 @@ MEASUREMENT_SENSORS: dict[ measurement_type=MeasurementType.HEART_RATE, translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, ), MeasurementType.SP02: WithingsMeasurementSensorEntityDescription( @@ -189,7 +187,6 @@ MEASUREMENT_SENSORS: dict[ translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, - icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -283,7 +280,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), @@ -292,7 +288,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -302,7 +297,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -312,7 +306,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.average_heart_rate, translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -321,7 +314,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.max_heart_rate, translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -330,7 +322,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.min_heart_rate, translation_key="minimum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -339,7 +330,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -349,7 +339,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -383,7 +372,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_score, translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, - icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -406,7 +394,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_count, translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, - icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -415,7 +402,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -435,7 +421,6 @@ ACTIVITY_SENSORS = [ key="activity_steps_today", value_fn=lambda activity: activity.steps, translation_key="activity_steps_today", - icon="mdi:shoe-print", native_unit_of_measurement="steps", state_class=SensorStateClass.TOTAL, ), @@ -444,7 +429,6 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.distance, translation_key="activity_distance_today", suggested_display_precision=0, - icon="mdi:map-marker-distance", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, @@ -453,7 +437,6 @@ ACTIVITY_SENSORS = [ key="activity_floors_climbed_today", value_fn=lambda activity: activity.elevation, translation_key="activity_elevation_today", - icon="mdi:stairs-up", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, @@ -532,7 +515,6 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { STEP_GOAL: WithingsGoalsSensorEntityDescription( key="step_goal", value_fn=lambda goals: goals.steps, - icon="mdi:shoe-print", translation_key="step_goal", native_unit_of_measurement="steps", state_class=SensorStateClass.MEASUREMENT, @@ -540,7 +522,6 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { SLEEP_GOAL: WithingsGoalsSensorEntityDescription( key="sleep_goal", value_fn=lambda goals: goals.sleep, - icon="mdi:bed-clock", translation_key="sleep_goal", native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, @@ -592,13 +573,11 @@ WORKOUT_SENSORS = [ device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, suggested_display_precision=0, - icon="mdi:map-marker-distance", ), WithingsWorkoutSensorEntityDescription( key="workout_floors_climbed", value_fn=lambda workout: workout.elevation, translation_key="workout_elevation", - icon="mdi:stairs-up", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, ), @@ -611,7 +590,6 @@ WORKOUT_SENSORS = [ key="workout_pause_duration", value_fn=lambda workout: workout.pause_duration or 0, translation_key="workout_pause_duration", - icon="mdi:timer-pause", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, @@ -622,7 +600,6 @@ WORKOUT_SENSORS = [ workout.end_date - workout.start_date ).total_seconds(), translation_key="workout_duration", - icon="mdi:timer", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, diff --git a/homeassistant/components/wled/binary_sensor.py b/homeassistant/components/wled/binary_sensor.py index a248ea57c7d..6191235f423 100644 --- a/homeassistant/components/wled/binary_sensor.py +++ b/homeassistant/components/wled/binary_sensor.py @@ -34,7 +34,7 @@ class WLEDUpdateBinarySensor(WLEDEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.UPDATE - _attr_name = "Firmware" + _attr_translation_key = "firmware" # Disabled by default, as this entity is deprecated. _attr_entity_registry_enabled_default = False diff --git a/homeassistant/components/wled/icons.json b/homeassistant/components/wled/icons.json new file mode 100644 index 00000000000..65de1d0f985 --- /dev/null +++ b/homeassistant/components/wled/icons.json @@ -0,0 +1,68 @@ +{ + "entity": { + "light": { + "main": { + "default": "mdi:led-strip-variant" + }, + "segment": { + "default": "mdi:led-strip-variant" + } + }, + "number": { + "speed": { + "default": "mdi:speedometer" + } + }, + "select": { + "preset": { + "default": "mdi:playlist-play" + }, + "playlist": { + "default": "mdi:play-speed" + }, + "color_palette": { + "default": "mdi:palette-outline" + }, + "segment_color_palette": { + "default": "mdi:palette-outline" + }, + "live_override": { + "default": "mdi:theater" + } + }, + "sensor": { + "free_heap": { + "default": "mdi:memory" + }, + "wifi_signal": { + "default": "mdi:wifi" + }, + "wifi_channel": { + "default": "mdi:wifi" + }, + "wifi_bssid": { + "default": "mdi:wifi" + }, + "ip": { + "default": "mdi:ip-network" + } + }, + "switch": { + "nightlight": { + "default": "mdi:weather-night" + }, + "sync_send": { + "default": "mdi:upload-network-outline" + }, + "sync_receive": { + "default": "mdi:download-network-outline" + }, + "reverse": { + "default": "mdi:swap-horizontal-bold" + }, + "segment_reverse": { + "default": "mdi:swap-horizontal-bold" + } + } + } +} diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index b793654c886..4327261d4be 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -51,7 +51,6 @@ class WLEDMainLight(WLEDEntity, LightEntity): """Defines a WLED main light.""" _attr_color_mode = ColorMode.BRIGHTNESS - _attr_icon = "mdi:led-strip-variant" _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -103,7 +102,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): """Defines a WLED light based on a segment.""" _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION - _attr_icon = "mdi:led-strip-variant" + _attr_translation_key = "segment" def __init__( self, @@ -121,7 +120,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if segment == 0: self._attr_name = None else: - self._attr_name = f"Segment {segment}" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( f"{self.coordinator.data.info.mac_address}_{self._segment}" diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 0fa7d464722..fd734c07fbc 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -49,8 +49,7 @@ class WLEDNumberEntityDescription(NumberEntityDescription): NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, - name="Speed", - icon="mdi:speedometer", + translation_key="speed", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -59,7 +58,7 @@ NUMBERS = [ ), WLEDNumberEntityDescription( key=ATTR_INTENSITY, - name="Intensity", + translation_key="intensity", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -87,7 +86,8 @@ class WLEDNumber(WLEDEntity, NumberEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} {description.name}" + self._attr_translation_key = f"segment_{description.translation_key}" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( f"{coordinator.data.info.mac_address}_{description.key}_{segment}" diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 977c76025ac..36aff0f4536 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -49,7 +49,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): """Defined a WLED Live Override select.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:theater" _attr_translation_key = "live_override" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -73,7 +72,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" - _attr_icon = "mdi:playlist-play" _attr_translation_key = "preset" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -104,7 +102,6 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): class WLEDPlaylistSelect(WLEDEntity, SelectEntity): """Define a WLED Playlist select.""" - _attr_icon = "mdi:play-speed" _attr_translation_key = "playlist" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -138,8 +135,7 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): """Defines a WLED Palette select.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:palette-outline" - _attr_name = "Color palette" + _attr_translation_key = "color_palette" _segment: int def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: @@ -149,7 +145,8 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} color palette" + self._attr_translation_key = "segment_color_palette" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" self._attr_options = [ diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 709edaf424f..a2e052eacd9 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -76,7 +76,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="free_heap", translation_key="free_heap", - icon="mdi:memory", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_SIZE, @@ -87,7 +86,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_signal", translation_key="wifi_signal", - icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -107,7 +105,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_channel", translation_key="wifi_channel", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.channel if device.info.wifi else None, @@ -115,7 +112,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_bssid", translation_key="wifi_bssid", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, @@ -123,7 +119,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="ip", translation_key="ip", - icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ip, ), diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml deleted file mode 100644 index 40170fd54e9..00000000000 --- a/homeassistant/components/wled/services.yaml +++ /dev/null @@ -1,41 +0,0 @@ -effect: - target: - entity: - integration: wled - domain: light - fields: - effect: - example: "Rainbow" - selector: - text: - intensity: - selector: - number: - min: 0 - max: 255 - palette: - example: "Tiamat" - selector: - text: - speed: - selector: - number: - min: 0 - max: 255 - reverse: - default: false - selector: - boolean: - -preset: - target: - entity: - integration: wled - domain: light - fields: - preset: - selector: - number: - min: -1 - max: 65535 - mode: box diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index eff6dfab572..9581641f545 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -35,12 +35,40 @@ } }, "entity": { + "binary_sensor": { + "firmware": { + "name": "Firmware" + } + }, "light": { "main": { "name": "Main" + }, + "segment": { + "name": "Segment {segment}" + } + }, + "number": { + "intensity": { + "name": "Intensity" + }, + "segment_intensity": { + "name": "Segment {segment} intensity" + }, + "speed": { + "name": "Speed" + }, + "segment_speed": { + "name": "Segment {segment} speed" } }, "select": { + "color_palette": { + "name": "Color palette" + }, + "segment_color_palette": { + "name": "Segment {segment} color palette" + }, "live_override": { "name": "Live override", "state": { @@ -97,44 +125,12 @@ }, "sync_receive": { "name": "Sync receive" - } - } - }, - "services": { - "effect": { - "name": "Set effect", - "description": "Controls the effect settings of WLED.", - "fields": { - "effect": { - "name": "Effect", - "description": "Name or ID of the WLED light effect." - }, - "intensity": { - "name": "Effect intensity", - "description": "Intensity of the effect. Number between 0 and 255." - }, - "palette": { - "name": "Color palette", - "description": "Name or ID of the WLED light palette." - }, - "speed": { - "name": "Effect speed", - "description": "Speed of the effect." - }, - "reverse": { - "name": "Reverse effect", - "description": "Reverse the effect. Either true to reverse or false otherwise." - } - } - }, - "preset": { - "name": "Set preset (deprecated)", - "description": "Sets a preset for the WLED device.", - "fields": { - "preset": { - "name": "Preset ID", - "description": "ID of the WLED preset." - } + }, + "reverse": { + "name": "Reverse" + }, + "segment_reverse": { + "name": "Segment {segment} reverse" } } } diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 680684e96df..f42e1cc7f9f 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -53,7 +53,6 @@ async def async_setup_entry( class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): """Defines a WLED nightlight switch.""" - _attr_icon = "mdi:weather-night" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "nightlight" @@ -91,7 +90,6 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync send switch.""" - _attr_icon = "mdi:upload-network-outline" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "sync_send" @@ -124,7 +122,6 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync receive switch.""" - _attr_icon = "mdi:download-network-outline" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "sync_receive" @@ -157,9 +154,8 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): class WLEDReverseSwitch(WLEDEntity, SwitchEntity): """Defines a WLED reverse effect switch.""" - _attr_icon = "mdi:swap-horizontal-bold" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Reverse" + _attr_translation_key = "reverse" _segment: int def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: @@ -169,7 +165,8 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} reverse" + self._attr_translation_key = "segment_reverse" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_reverse_{segment}" self._segment = segment diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index bda3a576563..04a3a2544c1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -1,4 +1,5 @@ """Sensor to indicate whether the current day is a workday.""" + from __future__ import annotations from datetime import date, timedelta @@ -53,7 +54,7 @@ def validate_dates(holiday_list: list[str]) -> list[str]: continue _range: timedelta = d2 - d1 for i in range(_range.days + 1): - day = d1 + timedelta(days=i) + day: date = d1 + timedelta(days=i) calc_holidays.append(day.strftime("%Y-%m-%d")) continue calc_holidays.append(add_date) @@ -123,25 +124,46 @@ 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, - }, - ) + if dt_util.parse_date(remove_holiday): + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_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, + }, + ) + else: + 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()): diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ae7c42c1868..05026ae6e99 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.39"] + "requirements": ["holidays==0.42"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index 905434f76ac..1221514da42 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -125,9 +125,9 @@ class HolidayFixFlow(RepairsFlow): 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() + return await self.async_step_fix_remove_holiday() - async def async_step_named_holiday( + async def async_step_fix_remove_holiday( self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle the options step of a fix flow.""" @@ -168,7 +168,7 @@ class HolidayFixFlow(RepairsFlow): {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, ) return self.async_show_form( - step_id="named_holiday", + step_id="fix_remove_holiday", data_schema=new_schema, description_placeholders={ CONF_COUNTRY: self.country if self.country else "-", diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index bbb76676f96..0e618beaf82 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -137,7 +137,7 @@ "title": "Configured named holiday {remove_holidays} for {title} does not exist", "fix_flow": { "step": { - "named_holiday": { + "fix_remove_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": { @@ -152,6 +152,26 @@ "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" } } + }, + "bad_date_holiday": { + "title": "Configured holiday date {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "fix_remove_holiday": { + "title": "[%key:component::workday::issues::bad_date_holiday::title%]", + "description": "Remove holiday date `{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/manifest.json b/homeassistant/components/wyoming/manifest.json index 7174683fd18..14cf9f77683 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.4.0"], + "requirements": ["wyoming==1.5.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 78f57ff4b01..ea7a7d5df0c 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -10,8 +10,9 @@ 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.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline -from wyoming.satellite import RunSatellite +from wyoming.satellite import PauseSatellite, RunSatellite from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection @@ -24,11 +25,14 @@ from .const import DOMAIN from .data import WyomingService from .devices import SatelliteDevice -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) _SAMPLES_PER_CHUNK: Final = 1024 _RECONNECT_SECONDS: Final = 10 _RESTART_SECONDS: Final = 3 +_PING_TIMEOUT: Final = 5 +_PING_SEND_DELAY: Final = 2 +_PIPELINE_FINISH_TIMEOUT: Final = 1 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -54,6 +58,7 @@ class WyomingSatellite: self._client: AsyncTcpClient | None = None self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) self._is_pipeline_running = False + self._pipeline_ended_event = asyncio.Event() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None self._muted_changed_event = asyncio.Event() @@ -71,25 +76,34 @@ class WyomingSatellite: try: # Check if satellite has been muted while self.device.is_muted: + _LOGGER.debug("Satellite 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() + await self._connect_and_loop() except asyncio.CancelledError: - raise + raise # don't restart except Exception: # pylint: disable=broad-exception-caught + # Ensure sensor is off (before restart) + self.device.set_is_active(False) + + # Wait to restart await self.on_restart() finally: - # Ensure sensor is off + # Ensure sensor is off (before stop) self.device.set_is_active(False) await self.on_stopped() def stop(self) -> None: """Signal satellite task to stop running.""" + # Tell satellite to stop running + self._send_pause() + + # Stop task loop self.is_running = False # Unblock waiting for unmuted @@ -98,7 +112,7 @@ class WyomingSatellite: async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" _LOGGER.warning( - "Unexpected error running satellite. Restarting in %s second(s)", + "Satellite has been disconnected. Reconnecting in %s second(s)", _RECONNECT_SECONDS, ) await asyncio.sleep(_RESTART_SECONDS) @@ -121,12 +135,23 @@ class WyomingSatellite: # ------------------------------------------------------------------------- + def _send_pause(self) -> None: + """Send a pause message to satellite.""" + if self._client is not None: + self.hass.async_create_background_task( + self._client.write_event(PauseSatellite().event()), + "pause satellite", + ) + 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) + # Send pause event so satellite can react immediately + self._send_pause() + self._muted_changed_event.set() self._muted_changed_event.clear() @@ -142,18 +167,20 @@ class WyomingSatellite: # 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) - + async def _connect_and_loop(self) -> None: + """Connect to satellite and run pipelines until an error occurs.""" while self.is_running and (not self.device.is_muted): try: await self._connect() break except ConnectionError: + self._client = None # client is not valid + await self.on_reconnect() - assert self._client is not None + if self._client is None: + return + _LOGGER.debug("Connected to satellite") if (not self.is_running) or self.device.is_muted: @@ -163,27 +190,94 @@ class WyomingSatellite: # Tell satellite that we're ready await self._client.write_event(RunSatellite().event()) - # Wait until we get RunPipeline event - run_pipeline: RunPipeline | None = None + # Run until stopped or muted 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") + await self._run_pipeline_loop() - if RunPipeline.is_type(run_event.type): - run_pipeline = RunPipeline.from_event(run_event) - break + async def _run_pipeline_loop(self) -> None: + """Run a pipeline one or more times.""" + assert self._client is not None + run_pipeline: RunPipeline | None = None + send_ping = True - _LOGGER.debug("Unexpected event from satellite: %s", run_event) + # Read events and check for pipeline end in parallel + pipeline_ended_task = self.hass.async_create_background_task( + self._pipeline_ended_event.wait(), "satellite pipeline ended" + ) + client_event_task = self.hass.async_create_background_task( + self._client.read_event(), "satellite event read" + ) + pending = {pipeline_ended_task, client_event_task} - assert run_pipeline is not None + while self.is_running and (not self.device.is_muted): + if send_ping: + # Ensure satellite is still connected + send_ping = False + self.hass.async_create_background_task( + self._send_delayed_ping(), "ping satellite" + ) + + async with asyncio.timeout(_PING_TIMEOUT): + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + if pipeline_ended_task in done: + # Pipeline run end event was received + _LOGGER.debug("Pipeline finished") + self._pipeline_ended_event.clear() + pipeline_ended_task = self.hass.async_create_background_task( + self._pipeline_ended_event.wait(), "satellite pipeline ended" + ) + pending.add(pipeline_ended_task) + + if (run_pipeline is not None) and run_pipeline.restart_on_end: + # Automatically restart pipeline. + # Used with "always on" streaming satellites. + self._run_pipeline_once(run_pipeline) + continue + + if client_event_task not in done: + continue + + client_event = client_event_task.result() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if Pong.is_type(client_event.type): + # Satellite is still there, send next ping + send_ping = True + elif Ping.is_type(client_event.type): + # Respond to ping from satellite + ping = Ping.from_event(client_event) + await self._client.write_event(Pong(text=ping.text).event()) + elif RunPipeline.is_type(client_event.type): + # Satellite requested pipeline run + run_pipeline = RunPipeline.from_event(client_event) + self._run_pipeline_once(run_pipeline) + elif ( + AudioChunk.is_type(client_event.type) and self._is_pipeline_running + ): + # 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) and self._is_pipeline_running: + # Stop pipeline + _LOGGER.debug("Client requested pipeline to stop") + self._audio_queue.put_nowait(b"") + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + # Next event + client_event_task = self.hass.async_create_background_task( + self._client.read_event(), "satellite event read" + ) + pending.add(client_event_task) + + def _run_pipeline_once(self, run_pipeline: RunPipeline) -> None: + """Run a pipeline once.""" _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) @@ -193,79 +287,64 @@ class WyomingSatellite: 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( + 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 + self._pipeline_ended_event.clear() + self.hass.async_create_background_task( + assist_pipeline.async_pipeline_from_audio_stream( self.hass, - DOMAIN, - self.device.satellite_id, - ) - pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) - assert pipeline is not None + 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, + ), + name="wyoming satellite pipeline", + ) - # We will push audio in through a queue - self._audio_queue = asyncio.Queue() - stt_stream = self._stt_stream() + async def _send_delayed_ping(self) -> None: + """Send ping to satellite after a delay.""" + assert self._client is not None - # 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") + try: + await asyncio.sleep(_PING_SEND_DELAY) + await self._client.write_event(Ping().event()) + except ConnectionError: + pass # handled with timeout def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: """Translate pipeline events into Wyoming events.""" @@ -274,6 +353,7 @@ class WyomingSatellite: if event.type == assist_pipeline.PipelineEventType.RUN_END: # Pipeline run is complete self._is_pipeline_running = False + self._pipeline_ended_event.set() self.device.set_is_active(False) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.hass.add_job(self._client.write_event(Detect().event())) @@ -413,10 +493,13 @@ class WyomingSatellite: 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") + try: + 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 + yield chunk + except asyncio.CancelledError: + pass # ignore diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index c04bad4bef8..99f26c3e440 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -57,7 +57,7 @@ class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelec self.device = device WyomingSatelliteEntity.__init__(self, device) - AssistPipelineSelect.__init__(self, hass, device.satellite_id) + AssistPipelineSelect.__init__(self, hass, DOMAIN, device.satellite_id) async def async_select_option(self, option: str) -> None: """Select an option.""" diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 7f1f11ba25d..37e11dd2693 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -123,7 +123,7 @@ class XboxData: presence: dict[str, PresenceData] -class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): +class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): # pylint: disable=hass-enforce-coordinator-module """Store Xbox Console Status.""" def __init__( diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index ced8c3cc471..3adafc6d05e 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast from xiaomi_ble import EncryptionScheme, SensorUpdate, XiaomiBluetoothDeviceData @@ -20,6 +21,7 @@ from homeassistant.helpers.device_registry import ( DeviceRegistry, async_get, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_DISCOVERED_EVENT_CLASSES, @@ -30,7 +32,7 @@ from .const import ( ) from .coordinator import XiaomiActiveBluetoothProcessorCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -47,7 +49,7 @@ def process_service_info( coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - discovered_device_classes = coordinator.discovered_device_classes + discovered_event_classes = coordinator.discovered_event_classes if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: hass.config_entries.async_update_entry( entry, @@ -67,28 +69,35 @@ def process_service_info( sw_version=sensor_device_info.sw_version, hw_version=sensor_device_info.hw_version, ) + # event_class may be postfixed with a number, ie 'button_2' + # but if there is only one button then it will be 'button' event_class = event.device_key.key event_type = event.event_type - if event_class not in discovered_device_classes: - discovered_device_classes.add(event_class) + ble_event = XiaomiBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + + if event_class not in discovered_event_classes: + discovered_event_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_event_classes)}, + ) + async_dispatcher_send( + hass, format_discovered_event_class(address), event_class, ble_event ) - hass.bus.async_fire( - XIAOMI_BLE_EVENT, - dict( - XiaomiBleEvent( - device_id=device.id, - address=address, - event_class=event_class, # ie 'button' - event_type=event_type, # ie 'press' - event_properties=event.event_properties, - ) - ), + hass.bus.async_fire(XIAOMI_BLE_EVENT, cast(dict, ble_event)) + async_dispatcher_send( + hass, + format_event_dispatcher_name(address, event_class), + ble_event, ) # If device isn't pending we know it has seen at least one broadcast with a payload @@ -103,6 +112,16 @@ def process_service_info( return update +def format_event_dispatcher_name(address: str, event_class: str) -> str: + """Format an event dispatcher name.""" + return f"{DOMAIN}_event_{address}_{event_class}" + + +def format_discovered_event_class(address: str) -> str: + """Format a discovered event class.""" + return f"{DOMAIN}_discovered_event_class_{address}" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Xiaomi BLE device from a config entry.""" address = entry.unique_id @@ -119,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - hass.state == CoreState.running + hass.state is CoreState.running and data.poll_needed(service_info, last_poll) and bool( async_ble_device_from_address( @@ -128,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_poll(service_info: BluetoothServiceInfoBleak): + async def _async_poll(service_info: BluetoothServiceInfoBleak) -> SensorUpdate: # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it # directly to the Xiaomi code # Make sure the device we have is one that we can connect with @@ -160,9 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), needs_poll_method=_needs_poll, device_data=data, - discovered_device_classes=set( - entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) - ), + discovered_event_classes=set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])), poll_method=_async_poll, # We will take advertisements from non-connectable devices # since we will trade the BLEDevice for a connectable one diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index 9115fc5991b..a0c03581eee 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -280,8 +280,8 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): # Otherwise there wasn't actually encryption so abort return self.async_abort(reason="reauth_successful") - def _async_get_or_create_entry(self, bindkey=None): - data = {} + def _async_get_or_create_entry(self, bindkey: str | None = None) -> FlowResult: + data: dict[str, Any] = {} if bindkey: data["bindkey"] = bindkey @@ -289,15 +289,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): if entry_id := self.context.get("entry_id"): entry = self.hass.config_entries.async_get_entry(entry_id) assert entry is not None - - self.hass.config_entries.async_update_entry(entry, data=data) - - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=data) return self.async_create_entry( title=self.context["title_placeholders"]["name"], diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 346d8a61318..1accfd9dc55 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -7,12 +7,31 @@ DOMAIN = "xiaomi_ble" CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" -CONF_SLEEPY_DEVICE: Final = "sleepy_device" CONF_EVENT_PROPERTIES: Final = "event_properties" -EVENT_PROPERTIES: Final = "event_properties" +CONF_EVENT_CLASS: Final = "event_class" +CONF_SLEEPY_DEVICE: Final = "sleepy_device" +CONF_SUBTYPE: Final = "subtype" + +EVENT_CLASS: Final = "event_class" EVENT_TYPE: Final = "event_type" +EVENT_SUBTYPE: Final = "event_subtype" +EVENT_PROPERTIES: Final = "event_properties" XIAOMI_BLE_EVENT: Final = "xiaomi_ble_event" +EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_MOTION: Final = "motion" + +BUTTON: Final = "button" +DOUBLE_BUTTON: Final = "double_button" +TRIPPLE_BUTTON: Final = "tripple_button" +MOTION: Final = "motion" + +BUTTON_PRESS: Final = "button_press" +BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" +DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" +TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" +MOTION_DEVICE: Final = "motion_device" + class XiaomiBleEvent(TypedDict): """Xiaomi BLE event data.""" diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 94e70ca9835..a935f3ea199 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -35,7 +35,7 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina update_method: Callable[[BluetoothServiceInfoBleak], Any], needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], device_data: XiaomiBluetoothDeviceData, - discovered_device_classes: set[str], + discovered_event_classes: set[str], poll_method: Callable[ [BluetoothServiceInfoBleak], Coroutine[Any, Any, Any], @@ -57,7 +57,7 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina poll_debouncer=poll_debouncer, connectable=connectable, ) - self.discovered_device_classes = discovered_device_classes + self.discovered_event_classes = discovered_event_classes self.device_data = device_data self.entry = entry diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 04239cee56d..6d29af9ac11 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -21,41 +21,89 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_EVENT_PROPERTIES, + BUTTON, + BUTTON_PRESS, + BUTTON_PRESS_DOUBLE_LONG, + CONF_SUBTYPE, DOMAIN, - EVENT_PROPERTIES, + DOUBLE_BUTTON, + DOUBLE_BUTTON_PRESS_DOUBLE_LONG, + EVENT_CLASS, + EVENT_CLASS_BUTTON, + EVENT_CLASS_MOTION, EVENT_TYPE, + MOTION, + MOTION_DEVICE, + TRIPPLE_BUTTON, + TRIPPLE_BUTTON_PRESS_DOUBLE_LONG, XIAOMI_BLE_EVENT, ) -MOTION_DEVICE_TRIGGERS = [ - {CONF_TYPE: "motion_detected", CONF_EVENT_PROPERTIES: None}, -] +TRIGGERS_BY_TYPE = { + BUTTON_PRESS: ["press"], + BUTTON_PRESS_DOUBLE_LONG: ["press", "double_press", "long_press"], + MOTION_DEVICE: ["motion_detected"], +} -MOTION_DEVICE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In( - [trigger[CONF_TYPE] for trigger in MOTION_DEVICE_TRIGGERS] - ), - vol.Optional(CONF_EVENT_PROPERTIES): vol.In( - [trigger[CONF_EVENT_PROPERTIES] for trigger in MOTION_DEVICE_TRIGGERS] - ), - } -) +EVENT_TYPES = { + BUTTON: ["button"], + DOUBLE_BUTTON: ["button_left", "button_right"], + TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + MOTION: ["motion"], +} @dataclass class TriggerModelData: """Data class for trigger model data.""" - triggers: list[dict[str, Any]] - schema: vol.Schema + event_class: str + event_types: list[str] + triggers: list[str] + + +TRIGGER_MODEL_DATA = { + BUTTON_PRESS: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS], + ), + BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + DOUBLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[DOUBLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[TRIPPLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + MOTION_DEVICE: TriggerModelData( + event_class=EVENT_CLASS_MOTION, + event_types=EVENT_TYPES[MOTION], + triggers=TRIGGERS_BY_TYPE[MOTION_DEVICE], + ), +} MODEL_DATA = { - "MUE4094RT": TriggerModelData( - triggers=MOTION_DEVICE_TRIGGERS, schema=MOTION_DEVICE_SCHEMA - ) + "JTYJGD03MI": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "MS1BB(MI)": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "RTCGQ02LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "SJWS01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "K9BB-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "YLAI003": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "XMWXKG01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "K9B-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], + "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], + "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "MUE4094RT": TRIGGER_MODEL_DATA[MOTION_DEVICE], } @@ -65,7 +113,13 @@ async def async_validate_trigger_config( """Validate trigger config.""" device_id = config[CONF_DEVICE_ID] if model_data := _async_trigger_model_data(hass, device_id): - return model_data.schema(config) + schema = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(model_data.event_types), + vol.Required(CONF_SUBTYPE): vol.In(model_data.triggers), + } + ) + return schema(config) # type: ignore[no-any-return] return config @@ -77,14 +131,21 @@ async def async_get_triggers( # Check if device is a model supporting device triggers. if not (model_data := _async_trigger_model_data(hass, device_id)): return [] + + event_types = model_data.event_types + event_subtypes = model_data.triggers return [ { + # Required fields of TRIGGER_BASE_SCHEMA CONF_PLATFORM: "device", - CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - **trigger, + CONF_DOMAIN: DOMAIN, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: event_type, + CONF_SUBTYPE: event_subtype, } - for trigger in model_data.triggers + for event_type in event_types + for event_subtype in event_subtypes ] @@ -95,19 +156,17 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - - event_data = { - CONF_DEVICE_ID: config[CONF_DEVICE_ID], - EVENT_TYPE: config[CONF_TYPE], - EVENT_PROPERTIES: config[CONF_EVENT_PROPERTIES], - } return await event_trigger.async_attach_trigger( hass, event_trigger.TRIGGER_SCHEMA( { event_trigger.CONF_PLATFORM: CONF_EVENT, event_trigger.CONF_EVENT_TYPE: XIAOMI_BLE_EVENT, - event_trigger.CONF_EVENT_DATA: event_data, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + EVENT_CLASS: config[CONF_TYPE], + EVENT_TYPE: config[CONF_SUBTYPE], + }, } ), action, diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py new file mode 100644 index 00000000000..1d5b08fb8f9 --- /dev/null +++ b/homeassistant/components/xiaomi_ble/event.py @@ -0,0 +1,130 @@ +"""Support for Xiaomi event entities.""" +from __future__ import annotations + +from dataclasses import replace + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import format_discovered_event_class, format_event_dispatcher_name +from .const import ( + DOMAIN, + EVENT_CLASS_BUTTON, + EVENT_CLASS_MOTION, + EVENT_PROPERTIES, + EVENT_TYPE, + XiaomiBleEvent, +) +from .coordinator import XiaomiActiveBluetoothProcessorCoordinator + +DESCRIPTIONS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: EventEntityDescription( + key=EVENT_CLASS_BUTTON, + translation_key="button", + event_types=[ + "press", + "double_press", + "long_press", + ], + device_class=EventDeviceClass.BUTTON, + ), + EVENT_CLASS_MOTION: EventEntityDescription( + key=EVENT_CLASS_MOTION, + translation_key="motion", + event_types=["motion_detected"], + ), +} + + +class XiaomiEventEntity(EventEntity): + """Representation of a Xiaomi event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + address: str, + event_class: str, + event: XiaomiBleEvent | None, + ) -> None: + """Initialise a Xiaomi event entity.""" + self._update_signal = format_event_dispatcher_name(address, event_class) + # event_class is something like "button" or "motion" + # and it maybe postfixed with "_1", "_2", "_3", etc + # If there is only one button then it will be "button" + base_event_class, _, postfix = event_class.partition("_") + base_description = DESCRIPTIONS_BY_EVENT_CLASS[base_event_class] + self.entity_description = replace(base_description, key=event_class) + postfix_name = f" {postfix}" if postfix else "" + self._attr_name = f"{base_event_class.title()}{postfix_name}" + # Matches logic in PassiveBluetoothProcessorEntity + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + self._attr_unique_id = f"{address}-{event_class}" + # If the event is provided then we can set the initial state + # since the event itself is likely what triggered the creation + # of this entity. We have to do this at creation time since + # entities are created dynamically and would otherwise miss + # the initial state. + if event: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._update_signal, + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: XiaomiBleEvent) -> None: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Xiaomi event.""" + coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + address = coordinator.address + ent_reg = er.async_get(hass) + async_add_entities( + # Matches logic in PassiveBluetoothProcessorEntity + XiaomiEventEntity(address_event_class[0], address_event_class[2], None) + for ent_reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + if ent_reg_entry.domain == "event" + and (address_event_class := ent_reg_entry.unique_id.partition("-")) + ) + + @callback + def _async_discovered_event_class(event_class: str, event: XiaomiBleEvent) -> None: + """Handle a newly discovered event class with or without a postfix.""" + async_add_entities([XiaomiEventEntity(address, event_class, event)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + format_discovered_event_class(address), + _async_discovered_event_class, + ) + ) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index a03e3f388ed..f11b2426f96 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.21.1"] + "requirements": ["xiaomi-ble==0.23.1"] } diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index d1bc6fa9a48..c7cbe43bd94 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -40,8 +40,42 @@ } }, "device_automation": { + "trigger_subtype": { + "press": "Press", + "double_press": "Double Press", + "long_press": "Long Press", + "motion_detected": "Motion Detected" + }, "trigger_type": { - "motion_detected": "Motion detected" + "button": "Button \"{subtype}\"", + "button_left": "Button Left \"{subtype}\"", + "button_middle": "Button Middle \"{subtype}\"", + "button_right": "Button Right \"{subtype}\"", + "motion": "{subtype}" + } + }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "double_press": "Double press", + "long_press": "Long press" + } + } + } + }, + "motion": { + "state_attributes": { + "event_type": { + "state": { + "motion_detected": "Motion Detected" + } + } + } + } } } } diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 2660a1b2be1..3e952c1ab3f 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -417,7 +417,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): 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/xs1/climate.py b/homeassistant/components/xs1/climate.py index 4c4f6682ffa..949d2330347 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -54,6 +54,7 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, sensor): """Initialize the actuator.""" diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 3a6d91c4f55..578519107cd 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -207,11 +207,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_SLOT], ) ): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( reauth_entry, data={**reauth_entry.data, **user_input} ) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_validate", diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index dcd7e57ce1f..c9ed4bc6a8f 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.4.0"] + "requirements": ["yalexs-ble==2.4.1"] } diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 307171487bc..5242aa90819 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -104,7 +104,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): +class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching data from the API.""" def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index c5cd6f906f5..a9834823f5e 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine import logging import math -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import voluptuous as vol import yeelight @@ -66,6 +67,10 @@ from .const import ( from .device import YeelightDevice from .entity import YeelightEntity +_YeelightBaseLightT = TypeVar("_YeelightBaseLightT", bound="YeelightBaseLight") +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) ATTR_MINUTES = "minutes" @@ -238,10 +243,14 @@ def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: return effects -def _async_cmd(func): +def _async_cmd( + func: Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R | None]]: """Define a wrapper to catch exceptions from the bulb.""" - async def _async_wrap(self: YeelightBaseLight, *args, **kwargs): + async def _async_wrap( + self: _YeelightBaseLightT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: for attempts in range(2): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) @@ -269,6 +278,7 @@ def _async_cmd(func): f"Error when calling {func.__name__} for bulb " f"{self.device.name} at {self.device.host}: {str(ex) or type(ex)}" ) from ex + return None return _async_wrap diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 20129b819ce..a1017a488d1 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -19,16 +19,21 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, + config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.typing import ConfigType from . import api from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS +from .services import async_register_services SCAN_INTERVAL = timedelta(minutes=5) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + PLATFORMS = [ Platform.BINARY_SENSOR, @@ -36,6 +41,7 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.LOCK, + Platform.NUMBER, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, @@ -94,6 +100,14 @@ class YoLinkHomeStore: device_coordinators: dict[str, YoLinkCoordinator] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up YoLink.""" + + async_register_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up yolink from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 0650cc3a203..0762a3b5c60 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -63,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="leak_state", device_class=BinarySensorDeviceClass.MOISTURE, - value=lambda value: value == "alert" if value is not None else None, + value=lambda value: value in ("alert", "full") if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, ), YoLinkBinarySensorEntityDescription( diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index 6e4495ee0b9..a1e2fdd90a2 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -62,6 +62,7 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """YoLink Climate Entity.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -86,6 +87,8 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @callback diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9fc4dac8ada..3d341c8b4fb 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,5 +7,10 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +ATTR_TARGET_DEVICE = "target_device" +ATTR_VOLUME = "volume" +ATTR_TEXT_MESSAGE = "message" +ATTR_REPEAT = "repeat" +ATTR_TONE = "tone" YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py new file mode 100644 index 00000000000..a7ba89e1f6c --- /dev/null +++ b/homeassistant/components/yolink/number.py @@ -0,0 +1,131 @@ +"""YoLink device number type config settings.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from yolink.client_request import ClientRequest +from yolink.const import ATTR_DEVICE_SPEAKER_HUB +from yolink.device import YoLinkDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + +OPTIONS_VALUME = "options_volume" + + +@dataclass(frozen=True, kw_only=True) +class YoLinkNumberTypeConfigEntityDescription(NumberEntityDescription): + """YoLink NumberEntity description.""" + + exists_fn: Callable[[YoLinkDevice], bool] + should_update_entity: Callable + value: Callable + + +NUMBER_TYPE_CONF_SUPPORT_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + +SUPPORT_SET_VOLUME_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + + +def get_volume_value(state: dict[str, Any]) -> int | None: + """Get volume option.""" + if (options := state.get("options")) is not None: + return options.get("volume") + return None + + +DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] = ( + YoLinkNumberTypeConfigEntityDescription( + key=OPTIONS_VALUME, + translation_key="config_volume", + native_min_value=1, + native_max_value=16, + mode=NumberMode.SLIDER, + native_step=1.0, + native_unit_of_measurement=None, + icon="mdi:volume-high", + exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES, + should_update_entity=lambda value: value is not None, + value=get_volume_value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up device number type config option entity from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + config_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in NUMBER_TYPE_CONF_SUPPORT_DEVICES + ] + entities = [] + for config_device_coordinator in config_device_coordinators: + for description in DEVICE_CONFIG_DESCRIPTIONS: + if description.exists_fn(config_device_coordinator.device): + entities.append( + YoLinkNumberTypeConfigEntity( + config_entry, + config_device_coordinator, + description, + ) + ) + async_add_entities(entities) + + +class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity): + """YoLink number type config Entity.""" + + entity_description: YoLinkNumberTypeConfigEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + description: YoLinkNumberTypeConfigEntityDescription, + ) -> None: + """Init YoLink device number type config entities.""" + super().__init__(config_entry, coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device.device_id} {description.key}" + + @callback + def update_entity_state(self, state: dict) -> None: + """Update HA Entity State.""" + if ( + attr_val := self.entity_description.value(state) + ) is None and self.entity_description.should_update_entity(attr_val) is False: + return + self._attr_native_value = attr_val + self.async_write_ha_state() + + async def update_speaker_hub_volume(self, volume: float) -> None: + """Update SpeakerHub volume.""" + await self.call_device(ClientRequest("setOption", {"volume": volume})) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + if ( + self.coordinator.device.device_type == ATTR_DEVICE_SPEAKER_HUB + and self.entity_description.key == OPTIONS_VALUME + ): + await self.update_speaker_hub_volume(value) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py new file mode 100644 index 00000000000..e41e3dce260 --- /dev/null +++ b/homeassistant/components/yolink/services.py @@ -0,0 +1,79 @@ +"""YoLink services.""" + +import voluptuous as vol +from yolink.client_request import ClientRequest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import ( + ATTR_REPEAT, + ATTR_TARGET_DEVICE, + ATTR_TEXT_MESSAGE, + ATTR_TONE, + ATTR_VOLUME, + DOMAIN, +) + +SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for YoLink integration.""" + + async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: + """Handle Speaker Hub audio play call.""" + service_data = service_call.data + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(service_data[ATTR_TARGET_DEVICE]) + if device_entry is not None: + for entry_id in device_entry.config_entries: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + break + if entry is None or entry.state == ConfigEntryState.NOT_LOADED: + raise ServiceValidationError( + "Config entry not found or not loaded!", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + ) + home_store = hass.data[DOMAIN][entry.entry_id] + for identifier in device_entry.identifiers: + if ( + device_coordinator := home_store.device_coordinators.get( + identifier[1] + ) + ) is not None: + tone_param = service_data[ATTR_TONE].capitalize() + play_request = ClientRequest( + "playAudio", + { + ATTR_TONE: tone_param, + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_VOLUME: service_data[ATTR_VOLUME], + ATTR_REPEAT: service_data[ATTR_REPEAT], + }, + ) + await device_coordinator.device.call_device(play_request) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_PLAY_ON_SPEAKER_HUB, + schema=vol.Schema( + { + vol.Required(ATTR_TARGET_DEVICE): cv.string, + vol.Required(ATTR_TONE): cv.string, + vol.Required(ATTR_TEXT_MESSAGE): cv.string, + vol.Required(ATTR_VOLUME): vol.All( + vol.Coerce(int), vol.Range(min=0, max=15) + ), + vol.Optional(ATTR_REPEAT, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10) + ), + }, + ), + service_func=handle_speaker_hub_play_call, + ) diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml new file mode 100644 index 00000000000..5f7a3ec3122 --- /dev/null +++ b/homeassistant/components/yolink/services.yaml @@ -0,0 +1,42 @@ +# SpeakerHub service +play_on_speaker_hub: + fields: + target_device: + required: true + selector: + device: + filter: + - integration: yolink + model: SpeakerHub + message: + required: true + example: hello, yolink + selector: + text: + tone: + required: true + default: "tip" + selector: + select: + options: + - "emergency" + - "alert" + - "warn" + - "tip" + translation_key: speaker_tone + volume: + required: true + default: 8 + selector: + number: + min: 0 + max: 15 + step: 1 + repeat: + required: true + default: 0 + selector: + number: + min: 0 + max: 10 + step: 1 diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 212d7ced7d7..83e712328f9 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -37,6 +37,11 @@ "button_4_long_press": "Button_4 (long press)" } }, + "exceptions": { + "invalid_config_entry": { + "message": "Config entry not found or not loaded!" + } + }, "entity": { "switch": { "usb_ports": { "name": "USB ports" }, @@ -69,6 +74,49 @@ "disabled": "[%key:common::state::disabled%]" } } + }, + "number": { + "config_volume": { + "name": "Volume" + } + } + }, + "services": { + "play_on_speaker_hub": { + "name": "Play on SpeakerHub", + "description": "Convert text to audio play on YoLink SpeakerHub", + "fields": { + "target_device": { + "name": "SpeakerHub Device", + "description": "SpeakerHub Device" + }, + "message": { + "name": "Text message", + "description": "Text message to be played." + }, + "tone": { + "name": "Tone", + "description": "Tone before playing audio." + }, + "volume": { + "name": "Volume", + "description": "Speaker volume during playback." + }, + "repeat": { + "name": "Repeat", + "description": "The amount of times the text will be repeated." + } + } + } + }, + "selector": { + "speaker_tone": { + "options": { + "emergency": "Emergency", + "alert": "Alert", + "warn": "Warn", + "tip": "Tip" + } } } } diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index c62c533be06..46c3b0d1902 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN @@ -37,11 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = YouTubeDataUpdateCoordinator(hass, auth) await coordinator.async_config_entry_first_refresh() + + await delete_devices(hass, entry, coordinator) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { COORDINATOR: coordinator, AUTH: auth, } - await hass.config_entries.async_forward_entry_setups(entry, list(PLATFORMS)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,3 +56,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def delete_devices( + hass: HomeAssistant, entry: ConfigEntry, coordinator: YouTubeDataUpdateCoordinator +) -> None: + """Delete all devices created by integration.""" + channel_ids = list(coordinator.data) + device_registry = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for dev_entry in dev_entries: + if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index f8a9008d9b3..b98169e3589 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -25,7 +25,7 @@ class AsyncConfigEntryAuth: @property def access_token(self) -> str: """Return the access token.""" - return self.oauth_session.token[CONF_ACCESS_TOKEN] + return self.oauth_session.token[CONF_ACCESS_TOKEN] # type: ignore[no-any-return] async def check_and_refresh_token(self) -> str: """Check the token.""" diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 380033e450a..7cd32d50d27 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -17,7 +17,7 @@ async def async_get_config_entry_diagnostics( coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ COORDINATOR ] - sensor_data = {} + sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): channel_data.get(ATTR_LATEST_VIDEO, {}).pop(ATTR_DESCRIPTION) sensor_data[channel_id] = channel_data diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index e7fe584c767..b094846ee22 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.3"] + "requirements": ["zamg==0.3.5"] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e12a7599d4d..2e058c4067c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -321,12 +321,11 @@ async def _async_register_hass_zc_service( 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] or "").lower(), matcher[key]) - ) + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True def is_homekit_paired(props: dict[str, Any]) -> bool: diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index bb7cfe67fb3..7f1f6a85d15 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -28,7 +28,7 @@ from .core import discovery from .core.cluster_handlers.security import ( SIGNAL_ALARM_TRIGGERED, SIGNAL_ARMED_STATE_CHANGED, - IasAce as AceClusterHandler, + IasAceClusterHandler, ) from .core.const import ( CLUSTER_HANDLER_IAS_ACE, @@ -96,7 +96,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Initialize the ZHA alarm control device.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) cfg_entry = zha_device.gateway.config_entry - self._cluster_handler: AceClusterHandler = cluster_handlers[0] + self._cluster_handler: IasAceClusterHandler = cluster_handlers[0] self._cluster_handler.panel_code = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" ) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9b057a3cbc3..5ec829fcb05 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -74,7 +74,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): _attribute_name: str - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Initialize the ZHA binary sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler = cluster_handlers[0] @@ -336,3 +336,16 @@ class AqaraLinkageAlarmState(BinarySensor): _unique_id_suffix = "linkage_alarm_state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE _attr_translation_key: str = "linkage_alarm_state" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorOpenedByHandBinarySensor(BinarySensor): + """Opened by hand binary sensor.""" + + _unique_id_suffix = "hand_open" + _attribute_name = "hand_open" + _attr_translation_key = "hand_open" + _attr_icon = "mdi:hand-wave" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 95abaf1c83e..cbc759e7008 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -141,6 +141,7 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key: str = "thermostat" + _enable_turn_on_off_backwards_compatibility = False def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" @@ -148,7 +149,11 @@ class Thermostat(ZhaEntity, ClimateEntity): self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) self._preset = PRESET_NONE self._presets = [] - self._supported_flags = ClimateEntityFeature.TARGET_TEMPERATURE + self._supported_flags = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) @property diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 00439343e81..6c65a993e95 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -262,7 +262,7 @@ class ClusterHandler(LogMixin): "id": attr, "name": attr_name, "change": config[2], - "success": False, + "status": None, } to_configure = [*self.REPORT_CONFIG] @@ -274,10 +274,7 @@ class ClusterHandler(LogMixin): reports = {rec["attr"]: rec["config"] for rec in chunk} try: res = await self.cluster.configure_reporting_multiple(reports, **kwargs) - self._configure_reporting_status(reports, res[0]) - # if we get a response, then it's a success - for attr_stat in event_data.values(): - attr_stat["success"] = True + self._configure_reporting_status(reports, res[0], event_data) except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( "failed to set reporting on '%s' cluster for: %s", @@ -304,7 +301,10 @@ class ClusterHandler(LogMixin): ) def _configure_reporting_status( - self, attrs: dict[str, tuple[int, int, float | int]], res: list | tuple + self, + attrs: dict[str, tuple[int, int, float | int]], + res: list | tuple, + event_data: dict[str, dict[str, Any]], ) -> None: """Parse configure reporting result.""" if isinstance(res, (Exception, ConfigureReportingResponseRecord)): @@ -315,6 +315,8 @@ class ClusterHandler(LogMixin): self.name, res, ) + for attr in attrs: + event_data[attr]["status"] = Status.FAILURE.name return if res[0].status == Status.SUCCESS and len(res) == 1: self.debug( @@ -323,24 +325,38 @@ class ClusterHandler(LogMixin): self.name, res, ) + # 2.5.8.1.3 Status Field + # The status field specifies the status of the Configure Reporting operation attempted on this attribute, as detailed in 2.5.7.3. + # Note that attribute status records are not included for successfully configured attributes, in order to save bandwidth. + # In the case of successful configuration of all attributes, only a single attribute status record SHALL be included in the command, + # with the status field set to SUCCESS and the direction and attribute identifier fields omitted. + for attr in attrs: + event_data[attr]["status"] = Status.SUCCESS.name return + for record in res: + event_data[self.cluster.find_attribute(record.attrid).name][ + "status" + ] = record.status.name failed = [ self.cluster.find_attribute(record.attrid).name for record in res if record.status != Status.SUCCESS ] - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster", - set(attrs) - set(failed), - self.name, - ) self.debug( "Failed to configure reporting for '%s' on '%s' cluster: %s", failed, self.name, res, ) + success = set(attrs) - set(failed) + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + set(attrs) - set(failed), + self.name, + ) + for attr in success: + event_data[attr]["status"] = Status.SUCCESS.name async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" @@ -408,10 +424,18 @@ class ClusterHandler(LogMixin): @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]", + self.name, + self.cluster.name, + attr_name, + value, + ) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self._get_attribute_name(attrid), + attr_name, value, ) @@ -553,7 +577,7 @@ class ClusterHandler(LogMixin): class ZDOClusterHandler(LogMixin): """Cluster handler for ZDO events.""" - def __init__(self, device): + def __init__(self, device) -> None: """Initialize ZDOClusterHandler.""" self.name = CLUSTER_HANDLER_ZDO self._cluster = device.device.endpoints[0] diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 16c7aef89ad..879765aec3c 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,10 +1,10 @@ """Closures cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -import zigpy.zcl -from zigpy.zcl.clusters import closures +import zigpy.types as t +from zigpy.zcl.clusters.closures import ConfigStatus, DoorLock, Shade, WindowCovering from homeassistant.core import callback @@ -12,25 +12,30 @@ from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -if TYPE_CHECKING: - from ..endpoint import Endpoint - -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): """Door lock cluster handler.""" _value_attribute = 0 REPORT_CONFIG = ( - AttrReportConfig(attr="lock_state", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig( + attr=DoorLock.AttributeDefs.lock_state.name, + config=REPORT_CONFIG_IMMEDIATE, + ), ) async def async_update(self): """Retrieve latest state.""" - result = await self.get_attribute_value("lock_state", from_cache=True) + result = await self.get_attribute_value( + DoorLock.AttributeDefs.lock_state.name, from_cache=True + ) if result is not None: self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + DoorLock.AttributeDefs.lock_state.id, + DoorLock.AttributeDefs.lock_state.name, + result, ) @callback @@ -45,7 +50,7 @@ class DoorLockClusterHandler(ClusterHandler): command_name = self._cluster.client_commands[command_id].name - if command_name == "operation_event_notification": + if command_name == DoorLock.ClientCommandDefs.operation_event_notification.name: self.zha_send_event( command_name, { @@ -72,20 +77,20 @@ class DoorLockClusterHandler(ClusterHandler): await self.set_pin_code( code_slot - 1, # start code slots at 1, Zigbee internals use 0 - closures.DoorLock.UserStatus.Enabled, - closures.DoorLock.UserType.Unrestricted, + DoorLock.UserStatus.Enabled, + DoorLock.UserType.Unrestricted, user_code, ) async def async_enable_user_code(self, code_slot: int) -> None: """Enable the code slot.""" - await self.set_user_status(code_slot - 1, closures.DoorLock.UserStatus.Enabled) + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Enabled) async def async_disable_user_code(self, code_slot: int) -> None: """Disable the code slot.""" - await self.set_user_status(code_slot - 1, closures.DoorLock.UserStatus.Disabled) + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Disabled) async def async_get_user_code(self, code_slot: int) -> int: """Get the user code from the code slot.""" @@ -115,77 +120,153 @@ class DoorLockClusterHandler(ClusterHandler): return result -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.Shade.cluster_id) -class Shade(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Shade.cluster_id) +class ShadeClusterHandler(ClusterHandler): """Shade cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCoveringClient(ClientClusterHandler): +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClientClusterHandler(ClientClusterHandler): """Window client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCovering(ClusterHandler): +@registries.BINDABLE_CLUSTERS.register(WindowCovering.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClusterHandler(ClusterHandler): """Window cluster handler.""" - _value_attribute_lift = ( - closures.WindowCovering.AttributeDefs.current_position_lift_percentage.id - ) - _value_attribute_tilt = ( - closures.WindowCovering.AttributeDefs.current_position_tilt_percentage.id - ) REPORT_CONFIG = ( AttrReportConfig( - attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE + attr=WindowCovering.AttributeDefs.current_position_lift_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, ), AttrReportConfig( - attr="current_position_tilt_percentage", config=REPORT_CONFIG_IMMEDIATE + attr=WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, ), ) - 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 + ZCL_INIT_ATTRS = { + WindowCovering.AttributeDefs.window_covering_type.name: True, + WindowCovering.AttributeDefs.window_covering_mode.name: True, + WindowCovering.AttributeDefs.config_status.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name: True, + WindowCovering.AttributeDefs.installed_open_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_open_limit_tilt.name: True, + } async def async_update(self): """Retrieve latest state.""" - result = await self.get_attribute_value( - "current_position_lift_percentage", from_cache=False + results = await self.get_attributes( + [ + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + ], + from_cache=False, + only_cache=False, ) - self.debug("read current position: %s", result) - if result is not None: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self._value_attribute_lift, - "current_position_lift_percentage", - result, + self.debug( + "read current_position_lift_percentage and current_position_tilt_percentage - results: %s", + results, + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name ) - result = await self.get_attribute_value( - "current_position_tilt_percentage", from_cache=False - ) - self.debug("read current tilt position: %s", result) - if result is not None: + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self._value_attribute_tilt, - "current_position_tilt_percentage", - result, + WindowCovering.AttributeDefs.current_position_lift_percentage.id, + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ), + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + WindowCovering.AttributeDefs.current_position_tilt_percentage.id, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ), ) - @callback - def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: - """Handle attribute update from window_covering cluster.""" - attr_name = self._get_attribute_name(attrid) - self.debug( - "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + @property + def inverted(self): + """Return true if the window covering is inverted.""" + config_status = self.cluster.get( + WindowCovering.AttributeDefs.config_status.name ) - if attrid in (self._value_attribute_lift, self._value_attribute_tilt): - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value - ) + return ( + config_status is not None + and ConfigStatus.Open_up_commands_reversed in ConfigStatus(config_status) + ) + + @property + def current_position_lift_percentage(self) -> t.uint16_t | None: + """Return the current lift percentage of the window covering.""" + lift_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ) + if lift_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + lift_percentage = 100 - lift_percentage + return lift_percentage + + @property + def current_position_tilt_percentage(self) -> t.uint16_t | None: + """Return the current tilt percentage of the window covering.""" + tilt_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + if tilt_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + tilt_percentage = 100 - tilt_percentage + return tilt_percentage + + @property + def installed_open_limit_lift(self) -> t.uint16_t | None: + """Return the installed open lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_lift.name + ) + + @property + def installed_closed_limit_lift(self) -> t.uint16_t | None: + """Return the installed closed lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_lift.name + ) + + @property + def installed_open_limit_tilt(self) -> t.uint16_t | None: + """Return the installed open tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_tilt.name + ) + + @property + def installed_closed_limit_tilt(self) -> t.uint16_t | None: + """Return the installed closed tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name + ) + + @property + def window_covering_type(self) -> WindowCovering.WindowCoveringType | None: + """Return the window covering type.""" + return self.cluster.get(WindowCovering.AttributeDefs.window_covering_type.name) diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 8bc6902b4ff..14401b260b2 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -8,7 +8,36 @@ from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t import zigpy.zcl -from zigpy.zcl.clusters import general +from zigpy.zcl.clusters.general import ( + Alarms, + AnalogInput, + AnalogOutput, + AnalogValue, + ApplianceControl, + Basic, + BinaryInput, + BinaryOutput, + BinaryValue, + Commissioning, + DeviceTemperature, + GreenPowerProxy, + Groups, + Identify, + LevelControl, + MultistateInput, + MultistateOutput, + MultistateValue, + OnOff, + OnOffConfiguration, + Ota, + Partition, + PollControl, + PowerConfiguration, + PowerProfile, + RSSILocation, + Scenes, + Time, +) from zigpy.zcl.foundation import Status from homeassistant.core import callback @@ -27,6 +56,7 @@ from ..const import ( SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, + UNKNOWN as ZHA_UNKNOWN, ) from . import ( AttrReportConfig, @@ -40,101 +70,110 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Alarms.cluster_id) -class Alarms(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Alarms.cluster_id) +class AlarmsClusterHandler(ClusterHandler): """Alarms cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInput.cluster_id) +class AnalogInputClusterHandler(ClusterHandler): """Analog Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=AnalogInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(ClusterHandler): +@registries.BINDABLE_CLUSTERS.register(AnalogOutput.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutput.cluster_id) +class AnalogOutputClusterHandler(ClusterHandler): """Analog Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=AnalogOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) ZCL_INIT_ATTRS = { - "min_present_value": True, - "max_present_value": True, - "resolution": True, - "relinquish_default": True, - "description": True, - "engineering_units": True, - "application_type": True, + AnalogOutput.AttributeDefs.min_present_value.name: True, + AnalogOutput.AttributeDefs.max_present_value.name: True, + AnalogOutput.AttributeDefs.resolution.name: True, + AnalogOutput.AttributeDefs.relinquish_default.name: True, + AnalogOutput.AttributeDefs.description.name: True, + AnalogOutput.AttributeDefs.engineering_units.name: True, + AnalogOutput.AttributeDefs.application_type.name: True, } @property def present_value(self) -> float | None: """Return cached value of present_value.""" - return self.cluster.get("present_value") + return self.cluster.get(AnalogOutput.AttributeDefs.present_value.name) @property def min_present_value(self) -> float | None: """Return cached value of min_present_value.""" - return self.cluster.get("min_present_value") + return self.cluster.get(AnalogOutput.AttributeDefs.min_present_value.name) @property def max_present_value(self) -> float | None: """Return cached value of max_present_value.""" - return self.cluster.get("max_present_value") + return self.cluster.get(AnalogOutput.AttributeDefs.max_present_value.name) @property def resolution(self) -> float | None: """Return cached value of resolution.""" - return self.cluster.get("resolution") + return self.cluster.get(AnalogOutput.AttributeDefs.resolution.name) @property def relinquish_default(self) -> float | None: """Return cached value of relinquish_default.""" - return self.cluster.get("relinquish_default") + return self.cluster.get(AnalogOutput.AttributeDefs.relinquish_default.name) @property def description(self) -> str | None: """Return cached value of description.""" - return self.cluster.get("description") + return self.cluster.get(AnalogOutput.AttributeDefs.description.name) @property def engineering_units(self) -> int | None: """Return cached value of engineering_units.""" - return self.cluster.get("engineering_units") + return self.cluster.get(AnalogOutput.AttributeDefs.engineering_units.name) @property def application_type(self) -> int | None: """Return cached value of application_type.""" - return self.cluster.get("application_type") + return self.cluster.get(AnalogOutput.AttributeDefs.application_type.name) async def async_set_present_value(self, value: float) -> None: """Update present_value.""" - await self.write_attributes_safe({"present_value": value}) + await self.write_attributes_safe( + {AnalogOutput.AttributeDefs.present_value.name: value} + ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValue.cluster_id) +class AnalogValueClusterHandler(ClusterHandler): """Analog Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=AnalogValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.ApplianceControl.cluster_id -) -class ApplianceContorl(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceControl.cluster_id) +class ApplianceControlClusterHandler(ClusterHandler): """Appliance Control cluster handler.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.Basic.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Basic.cluster_id) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Basic.cluster_id) class BasicClusterHandler(ClusterHandler): """Cluster handler to interact with the basic cluster.""" @@ -164,70 +203,80 @@ class BasicClusterHandler(ClusterHandler): ): self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["transmit_power"] = True + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["power_source"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) +class BinaryInputClusterHandler(ClusterHandler): """Binary Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=BinaryInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutput.cluster_id) +class BinaryOutputClusterHandler(ClusterHandler): """Binary Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=BinaryOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) +class BinaryValueClusterHandler(ClusterHandler): """Binary Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=BinaryValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Commissioning.cluster_id) -class Commissioning(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Commissioning.cluster_id) +class CommissioningClusterHandler(ClusterHandler): """Commissioning cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.DeviceTemperature.cluster_id -) -class DeviceTemperature(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceTemperature.cluster_id) +class DeviceTemperatureClusterHandler(ClusterHandler): """Device Temperature cluster handler.""" REPORT_CONFIG = ( { - "attr": "current_temperature", + "attr": DeviceTemperature.AttributeDefs.current_temperature.name, "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), }, ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.GreenPowerProxy.cluster_id) -class GreenPowerProxy(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GreenPowerProxy.cluster_id) +class GreenPowerProxyClusterHandler(ClusterHandler): """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Groups.cluster_id) -class Groups(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Groups.cluster_id) +class GroupsClusterHandler(ClusterHandler): """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Identify.cluster_id) -class Identify(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Identify.cluster_id) +class IdentifyClusterHandler(ClusterHandler): """Identify cluster handler.""" BIND: bool = False @@ -237,50 +286,64 @@ class Identify(ClusterHandler): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd == "trigger_effect": + if cmd == Identify.ServerCommandDefs.trigger_effect.name: self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) class LevelControlClientClusterHandler(ClientClusterHandler): """LevelControl client cluster.""" -@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +@registries.BINDABLE_CLUSTERS.register(LevelControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) class LevelControlClusterHandler(ClusterHandler): """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 - REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) + REPORT_CONFIG = ( + AttrReportConfig( + attr=LevelControl.AttributeDefs.current_level.name, + config=REPORT_CONFIG_ASAP, + ), + ) ZCL_INIT_ATTRS = { - "on_off_transition_time": True, - "on_level": True, - "on_transition_time": True, - "off_transition_time": True, - "default_move_rate": True, - "start_up_current_level": True, + LevelControl.AttributeDefs.on_off_transition_time.name: True, + LevelControl.AttributeDefs.on_level.name: True, + LevelControl.AttributeDefs.on_transition_time.name: True, + LevelControl.AttributeDefs.off_transition_time.name: True, + LevelControl.AttributeDefs.default_move_rate.name: True, + LevelControl.AttributeDefs.start_up_current_level.name: True, } @property def current_level(self) -> int | None: """Return cached value of the current_level attribute.""" - return self.cluster.get("current_level") + return self.cluster.get(LevelControl.AttributeDefs.current_level.name) @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd in ("move_to_level", "move_to_level_with_on_off"): + if cmd in ( + LevelControl.ServerCommandDefs.move_to_level.name, + LevelControl.ServerCommandDefs.move_to_level_with_on_off.name, + ): self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) - elif cmd in ("move", "move_with_on_off"): + elif cmd in ( + LevelControl.ServerCommandDefs.move.name, + LevelControl.ServerCommandDefs.move_with_on_off.name, + ): # We should dim slowly -- for now, just step once rate = args[1] if args[0] == 0xFF: rate = 10 # Should read default move rate self.dispatch_level_change(SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) - elif cmd in ("step", "step_with_on_off"): + elif cmd in ( + LevelControl.ServerCommandDefs.step.name, + LevelControl.ServerCommandDefs.step_with_on_off.name, + ): # Step (technically may change on/off) self.dispatch_level_change( SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1] @@ -298,49 +361,59 @@ class LevelControlClusterHandler(ClusterHandler): self.async_send_signal(f"{self.unique_id}_{command}", level) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInput.cluster_id) +class MultistateInputClusterHandler(ClusterHandler): """Multistate Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=MultistateInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.MultistateOutput.cluster_id -) -class MultistateOutput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutput.cluster_id) +class MultistateOutputClusterHandler(ClusterHandler): """Multistate Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=MultistateOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValue.cluster_id) +class MultistateValueClusterHandler(ClusterHandler): """Multistate Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=MultistateValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) class OnOffClientClusterHandler(ClientClusterHandler): """OnOff client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +@registries.BINDABLE_CLUSTERS.register(OnOff.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) class OnOffClusterHandler(ClusterHandler): """Cluster handler for the OnOff Zigbee cluster.""" - ON_OFF = general.OnOff.attributes_by_name["on_off"].id - REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) + REPORT_CONFIG = ( + AttrReportConfig( + attr=OnOff.AttributeDefs.on_off.name, config=REPORT_CONFIG_IMMEDIATE + ), + ) ZCL_INIT_ATTRS = { - "start_up_on_off": True, + OnOff.AttributeDefs.start_up_on_off.name: True, } def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -366,32 +439,38 @@ class OnOffClusterHandler(ClusterHandler): @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" - return self.cluster.get("on_off") + return self.cluster.get(OnOff.AttributeDefs.on_off.name) async def turn_on(self) -> None: """Turn the on off cluster on.""" result = await self.on() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn on: {result[1]}") - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) async def turn_off(self) -> None: """Turn the on off cluster off.""" result = await self.off() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn off: {result[1]}") - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd in ("off", "off_with_effect"): - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) - elif cmd in ("on", "on_with_recall_global_scene"): - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) - elif cmd == "on_with_timed_off": + if cmd in ( + OnOff.ServerCommandDefs.off.name, + OnOff.ServerCommandDefs.off_with_effect.name, + ): + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) + elif cmd in ( + OnOff.ServerCommandDefs.on.name, + OnOff.ServerCommandDefs.on_with_recall_global_scene.name, + ): + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) + elif cmd == OnOff.ServerCommandDefs.on_with_timed_off.name: should_accept = args[0] on_time = args[1] # 0 is always accept 1 is only accept when already on @@ -399,7 +478,9 @@ class OnOffClusterHandler(ClusterHandler): if self._off_listener is not None: self._off_listener() self._off_listener = None - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) + self.cluster.update_attribute( + OnOff.AttributeDefs.on_off.id, t.Bool.true + ) if on_time > 0: self._off_listener = async_call_later( self._endpoint.device.hass, @@ -407,20 +488,25 @@ class OnOffClusterHandler(ClusterHandler): self.set_to_off, ) elif cmd == "toggle": - self.cluster.update_attribute(self.ON_OFF, not bool(self.on_off)) + self.cluster.update_attribute( + OnOff.AttributeDefs.on_off.id, not bool(self.on_off) + ) @callback def set_to_off(self, *_): """Set the state to off.""" self._off_listener = None - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" - if attrid == self.ON_OFF: + if attrid == OnOff.AttributeDefs.on_off.id: self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, "on_off", value + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + OnOff.AttributeDefs.on_off.name, + value, ) async def async_update(self): @@ -429,24 +515,59 @@ class OnOffClusterHandler(ClusterHandler): return from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + await self.get_attribute_value( + OnOff.AttributeDefs.on_off.id, from_cache=from_cache + ) await super().async_update() -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.OnOffConfiguration.cluster_id -) -class OnOffConfiguration(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOffConfiguration.cluster_id) +class OnOffConfigurationClusterHandler(ClusterHandler): """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) -class Ota(ClientClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClusterHandler(ClusterHandler): """OTA cluster handler.""" BIND: bool = False + # Some devices have this cluster in the wrong collection (e.g. Third Reality) + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> str: + """Return cached value of current_file_version attribute.""" + current_file_version = self.cluster.get( + Ota.AttributeDefs.current_file_version.name + ) + if current_file_version is not None: + return f"0x{int(current_file_version):08x}" + return ZHA_UNKNOWN + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClientClusterHandler(ClientClusterHandler): + """OTA client cluster handler.""" + + BIND: bool = False + + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> str: + """Return cached value of current_file_version attribute.""" + current_file_version = self.cluster.get( + Ota.AttributeDefs.current_file_version.name + ) + if current_file_version is not None: + return f"0x{int(current_file_version):08x}" + return ZHA_UNKNOWN + @callback def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None @@ -458,19 +579,26 @@ class Ota(ClientClusterHandler): cmd_name = command_id signal_id = self._endpoint.unique_id.split("-")[0] - if cmd_name == "query_next_image": + if cmd_name == Ota.ServerCommandDefs.query_next_image.name: assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) + async def async_check_for_update(self): + """Check for firmware availability by issuing an image notify command.""" + await self.cluster.image_notify( + payload_type=(self.cluster.ImageNotifyCommand.PayloadType.QueryJitter), + query_jitter=100, + ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Partition.cluster_id) -class Partition(ClusterHandler): + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) +class PartitionClusterHandler(ClusterHandler): """Partition cluster handler.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.PollControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PollControl.cluster_id) -class PollControl(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PollControl.cluster_id) +class PollControlClusterHandler(ClusterHandler): """Poll Control cluster handler.""" CHECKIN_INTERVAL = 55 * 60 * 4 # 55min @@ -482,7 +610,9 @@ class PollControl(ClusterHandler): async def async_configure_cluster_handler_specific(self) -> None: """Configure cluster handler: set check-in interval.""" - await self.write_attributes_safe({"checkin_interval": self.CHECKIN_INTERVAL}) + await self.write_attributes_safe( + {PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL} + ) @callback def cluster_command( @@ -496,7 +626,7 @@ class PollControl(ClusterHandler): self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) self.zha_send_event(cmd_name, args) - if cmd_name == "checkin": + if cmd_name == PollControl.ClientCommandDefs.checkin.name: self.cluster.create_catching_task(self.check_in_response(tsn)) async def check_in_response(self, tsn: int) -> None: @@ -512,50 +642,52 @@ class PollControl(ClusterHandler): self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.PowerConfiguration.cluster_id -) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerConfiguration.cluster_id) class PowerConfigurationClusterHandler(ClusterHandler): """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( - AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), AttrReportConfig( - attr="battery_percentage_remaining", config=REPORT_CONFIG_BATTERY_SAVE + attr=PowerConfiguration.AttributeDefs.battery_voltage.name, + config=REPORT_CONFIG_BATTERY_SAVE, + ), + AttrReportConfig( + attr=PowerConfiguration.AttributeDefs.battery_percentage_remaining.name, + config=REPORT_CONFIG_BATTERY_SAVE, ), ) def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: """Initialize cluster handler specific attrs.""" attributes = [ - "battery_size", - "battery_quantity", + PowerConfiguration.AttributeDefs.battery_size.name, + PowerConfiguration.AttributeDefs.battery_quantity.name, ] return self.get_attributes( attributes, from_cache=from_cache, only_cache=from_cache ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PowerProfile.cluster_id) -class PowerProfile(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerProfile.cluster_id) +class PowerProfileClusterHandler(ClusterHandler): """Power Profile cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.RSSILocation.cluster_id) -class RSSILocation(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RSSILocation.cluster_id) +class RSSILocationClusterHandler(ClusterHandler): """RSSI Location cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) class ScenesClientClusterHandler(ClientClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) -class Scenes(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) +class ScenesClusterHandler(ClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Time.cluster_id) -class Time(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Time.cluster_id) +class TimeClusterHandler(ClusterHandler): """Time cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/helpers.py b/homeassistant/components/zha/core/cluster_handlers/helpers.py index 17bc5763977..f4444f3995c 100644 --- a/homeassistant/components/zha/core/cluster_handlers/helpers.py +++ b/homeassistant/components/zha/core/cluster_handlers/helpers.py @@ -13,3 +13,10 @@ def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: "SML003", "SML004", ) + + +def is_sonoff_presence_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Sonoff sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "SONOFF", + ) and cluster_handler.cluster.endpoint.model in ("SNZB-06P",) diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index a379db54dac..bb7b96d367e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -3,7 +3,14 @@ from __future__ import annotations import enum -from zigpy.zcl.clusters import homeautomation +from zigpy.zcl.clusters.homeautomation import ( + ApplianceEventAlerts, + ApplianceIdentification, + ApplianceStatistics, + Diagnostic, + ElectricalMeasurement, + MeterIdentification, +) from .. import registries from ..const import ( @@ -15,37 +22,27 @@ from ..const import ( from . import AttrReportConfig, ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceEventAlerts.cluster_id -) -class ApplianceEventAlerts(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceEventAlerts.cluster_id) +class ApplianceEventAlertsClusterHandler(ClusterHandler): """Appliance Event Alerts cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceIdentification.cluster_id -) -class ApplianceIdentification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceIdentification.cluster_id) +class ApplianceIdentificationClusterHandler(ClusterHandler): """Appliance Identification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceStatistics.cluster_id -) -class ApplianceStatistics(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceStatistics.cluster_id) +class ApplianceStatisticsClusterHandler(ClusterHandler): """Appliance Statistics cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.Diagnostic.cluster_id -) -class Diagnostic(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Diagnostic.cluster_id) +class DiagnosticClusterHandler(ClusterHandler): """Diagnostic cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ElectricalMeasurement.cluster_id -) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ElectricalMeasurement.cluster_id) class ElectricalMeasurementClusterHandler(ClusterHandler): """Cluster handler that polls active power level.""" @@ -65,29 +62,56 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): POWER_QUALITY_MEASUREMENT = 256 REPORT_CONFIG = ( - AttrReportConfig(attr="active_power", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="active_power_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="apparent_power", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_current", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_current_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="rms_voltage", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_voltage_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="ac_frequency", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="ac_frequency_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.apparent_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency_max.name, + config=REPORT_CONFIG_DEFAULT, + ), ) ZCL_INIT_ATTRS = { - "ac_current_divisor": True, - "ac_current_multiplier": True, - "ac_power_divisor": True, - "ac_power_multiplier": True, - "ac_voltage_divisor": True, - "ac_voltage_multiplier": True, - "ac_frequency_divisor": True, - "ac_frequency_multiplier": True, - "measurement_type": True, - "power_divisor": True, - "power_multiplier": True, - "power_factor": True, + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.measurement_type.name: True, + ElectricalMeasurement.AttributeDefs.power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.power_factor.name: True, } async def async_update(self): @@ -113,51 +137,89 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): @property def ac_current_divisor(self) -> int: """Return ac current divisor.""" - return self.cluster.get("ac_current_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name + ) + or 1 + ) @property def ac_current_multiplier(self) -> int: """Return ac current multiplier.""" - return self.cluster.get("ac_current_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name + ) + or 1 + ) @property def ac_voltage_divisor(self) -> int: """Return ac voltage divisor.""" - return self.cluster.get("ac_voltage_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name + ) + or 1 + ) @property def ac_voltage_multiplier(self) -> int: """Return ac voltage multiplier.""" - return self.cluster.get("ac_voltage_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name + ) + or 1 + ) @property def ac_frequency_divisor(self) -> int: """Return ac frequency divisor.""" - return self.cluster.get("ac_frequency_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name + ) + or 1 + ) @property def ac_frequency_multiplier(self) -> int: """Return ac frequency multiplier.""" - return self.cluster.get("ac_frequency_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name + ) + or 1 + ) @property def ac_power_divisor(self) -> int: """Return active power divisor.""" return self.cluster.get( - "ac_power_divisor", self.cluster.get("power_divisor") or 1 + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_divisor.name) + or 1, ) @property def ac_power_multiplier(self) -> int: """Return active power divisor.""" return self.cluster.get( - "ac_power_multiplier", self.cluster.get("power_multiplier") or 1 + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_multiplier.name) + or 1, ) @property def measurement_type(self) -> str | None: """Return Measurement type.""" - if (meas_type := self.cluster.get("measurement_type")) is None: + if ( + meas_type := self.cluster.get( + ElectricalMeasurement.AttributeDefs.measurement_type.name + ) + ) is None: return None meas_type = self.MeasurementType(meas_type) @@ -168,8 +230,6 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.MeterIdentification.cluster_id -) -class MeterIdentification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MeterIdentification.cluster_id) +class MeterIdentificationClusterHandler(ClusterHandler): """Metering Identification cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 5e41785a6d8..4c03d31135e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -7,7 +7,13 @@ from __future__ import annotations from typing import Any -from zigpy.zcl.clusters import hvac +from zigpy.zcl.clusters.hvac import ( + Dehumidification, + Fan, + Pump, + Thermostat, + UserInterface, +) from homeassistant.core import callback @@ -25,37 +31,41 @@ REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Dehumidification.cluster_id) -class Dehumidification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Dehumidification.cluster_id) +class DehumidificationClusterHandler(ClusterHandler): """Dehumidification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Fan.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Fan.cluster_id) class FanClusterHandler(ClusterHandler): """Fan cluster handler.""" _value_attribute = 0 - REPORT_CONFIG = (AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_OP),) - ZCL_INIT_ATTRS = {"fan_mode_sequence": True} + REPORT_CONFIG = ( + AttrReportConfig(attr=Fan.AttributeDefs.fan_mode.name, config=REPORT_CONFIG_OP), + ) + ZCL_INIT_ATTRS = {Fan.AttributeDefs.fan_mode_sequence.name: True} @property def fan_mode(self) -> int | None: """Return current fan mode.""" - return self.cluster.get("fan_mode") + return self.cluster.get(Fan.AttributeDefs.fan_mode.name) @property def fan_mode_sequence(self) -> int | None: """Return possible fan mode speeds.""" - return self.cluster.get("fan_mode_sequence") + return self.cluster.get(Fan.AttributeDefs.fan_mode_sequence.name) async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" - await self.write_attributes_safe({"fan_mode": value}) + await self.write_attributes_safe({Fan.AttributeDefs.fan_mode.name: value}) async def async_update(self) -> None: """Retrieve latest state.""" - await self.get_attribute_value("fan_mode", from_cache=False) + await self.get_attribute_value( + Fan.AttributeDefs.fan_mode.name, from_cache=False + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -70,78 +80,116 @@ class FanClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Pump.cluster_id) -class Pump(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Pump.cluster_id) +class PumpClusterHandler(ClusterHandler): """Pump cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Thermostat.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Thermostat.cluster_id) class ThermostatClusterHandler(ClusterHandler): """Thermostat cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), AttrReportConfig( - attr="occupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.local_temperature.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="occupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.occupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="unoccupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.occupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="unoccupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.unoccupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_state.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.system_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_CLIMATE_DISCRETE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_cooling_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_heating_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, ), - AttrReportConfig(attr="running_mode", config=REPORT_CONFIG_CLIMATE), - AttrReportConfig(attr="running_state", config=REPORT_CONFIG_CLIMATE_DEMAND), - AttrReportConfig(attr="system_mode", config=REPORT_CONFIG_CLIMATE), - AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_CLIMATE_DISCRETE), - AttrReportConfig(attr="pi_cooling_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), - AttrReportConfig(attr="pi_heating_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), ) ZCL_INIT_ATTRS: dict[str, bool] = { - "abs_min_heat_setpoint_limit": True, - "abs_max_heat_setpoint_limit": True, - "abs_min_cool_setpoint_limit": True, - "abs_max_cool_setpoint_limit": True, - "ctrl_sequence_of_oper": False, - "max_cool_setpoint_limit": True, - "max_heat_setpoint_limit": True, - "min_cool_setpoint_limit": True, - "min_heat_setpoint_limit": True, - "local_temperature_calibration": True, + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name: False, + Thermostat.AttributeDefs.max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.local_temperature_calibration.name: True, + Thermostat.AttributeDefs.setpoint_change_source.name: True, } @property def abs_max_cool_setpoint_limit(self) -> int: """Absolute maximum cooling setpoint.""" - return self.cluster.get("abs_max_cool_setpoint_limit", 3200) + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name, 3200 + ) @property def abs_min_cool_setpoint_limit(self) -> int: """Absolute minimum cooling setpoint.""" - return self.cluster.get("abs_min_cool_setpoint_limit", 1600) + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name, 1600 + ) @property def abs_max_heat_setpoint_limit(self) -> int: """Absolute maximum heating setpoint.""" - return self.cluster.get("abs_max_heat_setpoint_limit", 3000) + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name, 3000 + ) @property def abs_min_heat_setpoint_limit(self) -> int: """Absolute minimum heating setpoint.""" - return self.cluster.get("abs_min_heat_setpoint_limit", 700) + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name, 700 + ) @property def ctrl_sequence_of_oper(self) -> int: """Control Sequence of operations attribute.""" - return self.cluster.get("ctrl_sequence_of_oper", 0xFF) + return self.cluster.get( + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name, 0xFF + ) @property def max_cool_setpoint_limit(self) -> int: """Maximum cooling setpoint.""" - sp_limit = self.cluster.get("max_cool_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_cool_setpoint_limit.name + ) if sp_limit is None: return self.abs_max_cool_setpoint_limit return sp_limit @@ -149,7 +197,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def min_cool_setpoint_limit(self) -> int: """Minimum cooling setpoint.""" - sp_limit = self.cluster.get("min_cool_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_cool_setpoint_limit.name + ) if sp_limit is None: return self.abs_min_cool_setpoint_limit return sp_limit @@ -157,7 +207,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def max_heat_setpoint_limit(self) -> int: """Maximum heating setpoint.""" - sp_limit = self.cluster.get("max_heat_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_heat_setpoint_limit.name + ) if sp_limit is None: return self.abs_max_heat_setpoint_limit return sp_limit @@ -165,7 +217,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def min_heat_setpoint_limit(self) -> int: """Minimum heating setpoint.""" - sp_limit = self.cluster.get("min_heat_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_heat_setpoint_limit.name + ) if sp_limit is None: return self.abs_min_heat_setpoint_limit return sp_limit @@ -173,57 +227,61 @@ class ThermostatClusterHandler(ClusterHandler): @property def local_temperature(self) -> int | None: """Thermostat temperature.""" - return self.cluster.get("local_temperature") + return self.cluster.get(Thermostat.AttributeDefs.local_temperature.name) @property def occupancy(self) -> int | None: """Is occupancy detected.""" - return self.cluster.get("occupancy") + return self.cluster.get(Thermostat.AttributeDefs.occupancy.name) @property def occupied_cooling_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self.cluster.get("occupied_cooling_setpoint") + return self.cluster.get(Thermostat.AttributeDefs.occupied_cooling_setpoint.name) @property def occupied_heating_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self.cluster.get("occupied_heating_setpoint") + return self.cluster.get(Thermostat.AttributeDefs.occupied_heating_setpoint.name) @property def pi_cooling_demand(self) -> int: """Cooling demand.""" - return self.cluster.get("pi_cooling_demand") + return self.cluster.get(Thermostat.AttributeDefs.pi_cooling_demand.name) @property def pi_heating_demand(self) -> int: """Heating demand.""" - return self.cluster.get("pi_heating_demand") + return self.cluster.get(Thermostat.AttributeDefs.pi_heating_demand.name) @property def running_mode(self) -> int | None: """Thermostat running mode.""" - return self.cluster.get("running_mode") + return self.cluster.get(Thermostat.AttributeDefs.running_mode.name) @property def running_state(self) -> int | None: """Thermostat running state, state of heat, cool, fan relays.""" - return self.cluster.get("running_state") + return self.cluster.get(Thermostat.AttributeDefs.running_state.name) @property def system_mode(self) -> int | None: """System mode.""" - return self.cluster.get("system_mode") + return self.cluster.get(Thermostat.AttributeDefs.system_mode.name) @property def unoccupied_cooling_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self.cluster.get("unoccupied_cooling_setpoint") + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + ) @property def unoccupied_heating_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self.cluster.get("unoccupied_heating_setpoint") + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -241,14 +299,20 @@ class ThermostatClusterHandler(ClusterHandler): async def async_set_operation_mode(self, mode) -> bool: """Set Operation mode.""" - await self.write_attributes_safe({"system_mode": mode}) + await self.write_attributes_safe( + {Thermostat.AttributeDefs.system_mode.name: mode} + ) return True async def async_set_heating_setpoint( self, temperature: int, is_away: bool = False ) -> bool: """Set heating setpoint.""" - attr = "unoccupied_heating_setpoint" if is_away else "occupied_heating_setpoint" + attr = ( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_heating_setpoint.name + ) await self.write_attributes_safe({attr: temperature}) return True @@ -256,19 +320,27 @@ class ThermostatClusterHandler(ClusterHandler): self, temperature: int, is_away: bool = False ) -> bool: """Set cooling setpoint.""" - attr = "unoccupied_cooling_setpoint" if is_away else "occupied_cooling_setpoint" + attr = ( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_cooling_setpoint.name + ) await self.write_attributes_safe({attr: temperature}) return True async def get_occupancy(self) -> bool | None: """Get unreportable occupancy attribute.""" - res, fail = await self.read_attributes(["occupancy"]) + res, fail = await self.read_attributes( + [Thermostat.AttributeDefs.occupancy.name] + ) self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) - if "occupancy" not in res: + if Thermostat.AttributeDefs.occupancy.name not in res: return None return bool(self.occupancy) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.UserInterface.cluster_id) -class UserInterface(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) +class UserInterfaceClusterHandler(ClusterHandler): """User interface (thermostat) cluster handler.""" + + ZCL_INIT_ATTRS = {UserInterface.AttributeDefs.keypad_lockout.name: True} diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 5f1e52fa241..bb3ac3c80e3 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -1,7 +1,7 @@ """Lighting cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -from zigpy.zcl.clusters import lighting +from zigpy.zcl.clusters.lighting import Ballast, Color from homeassistant.backports.functools import cached_property @@ -10,93 +10,112 @@ from ..const import REPORT_CONFIG_DEFAULT from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Ballast.cluster_id) -class Ballast(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ballast.cluster_id) +class BallastClusterHandler(ClusterHandler): """Ballast cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) class ColorClientClusterHandler(ClientClusterHandler): """Color client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(lighting.Color.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +@registries.BINDABLE_CLUSTERS.register(Color.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) class ColorClusterHandler(ClusterHandler): """Color cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=Color.AttributeDefs.current_x.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_y.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_hue.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_saturation.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.color_temperature.name, + config=REPORT_CONFIG_DEFAULT, + ), ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 ZCL_INIT_ATTRS = { - "color_mode": False, - "color_temp_physical_min": True, - "color_temp_physical_max": True, - "color_capabilities": True, - "color_loop_active": False, - "enhanced_current_hue": False, - "start_up_color_temperature": True, - "options": True, + Color.AttributeDefs.color_mode.name: False, + Color.AttributeDefs.color_temp_physical_min.name: True, + Color.AttributeDefs.color_temp_physical_max.name: True, + Color.AttributeDefs.color_capabilities.name: True, + Color.AttributeDefs.color_loop_active.name: False, + Color.AttributeDefs.enhanced_current_hue.name: False, + Color.AttributeDefs.start_up_color_temperature.name: True, + Color.AttributeDefs.options.name: True, } @cached_property - def color_capabilities(self) -> lighting.Color.ColorCapabilities: + def color_capabilities(self) -> Color.ColorCapabilities: """Return ZCL color capabilities of the light.""" - color_capabilities = self.cluster.get("color_capabilities") + color_capabilities = self.cluster.get( + Color.AttributeDefs.color_capabilities.name + ) if color_capabilities is None: - return lighting.Color.ColorCapabilities.XY_attributes - return lighting.Color.ColorCapabilities(color_capabilities) + return Color.ColorCapabilities.XY_attributes + return Color.ColorCapabilities(color_capabilities) @property def color_mode(self) -> int | None: """Return cached value of the color_mode attribute.""" - return self.cluster.get("color_mode") + return self.cluster.get(Color.AttributeDefs.color_mode.name) @property def color_loop_active(self) -> int | None: """Return cached value of the color_loop_active attribute.""" - return self.cluster.get("color_loop_active") + return self.cluster.get(Color.AttributeDefs.color_loop_active.name) @property def color_temperature(self) -> int | None: """Return cached value of color temperature.""" - return self.cluster.get("color_temperature") + return self.cluster.get(Color.AttributeDefs.color_temperature.name) @property def current_x(self) -> int | None: """Return cached value of the current_x attribute.""" - return self.cluster.get("current_x") + return self.cluster.get(Color.AttributeDefs.current_x.name) @property def current_y(self) -> int | None: """Return cached value of the current_y attribute.""" - return self.cluster.get("current_y") + return self.cluster.get(Color.AttributeDefs.current_y.name) @property def current_hue(self) -> int | None: """Return cached value of the current_hue attribute.""" - return self.cluster.get("current_hue") + return self.cluster.get(Color.AttributeDefs.current_hue.name) @property def enhanced_current_hue(self) -> int | None: """Return cached value of the enhanced_current_hue attribute.""" - return self.cluster.get("enhanced_current_hue") + return self.cluster.get(Color.AttributeDefs.enhanced_current_hue.name) @property def current_saturation(self) -> int | None: """Return cached value of the current_saturation attribute.""" - return self.cluster.get("current_saturation") + return self.cluster.get(Color.AttributeDefs.current_saturation.name) @property def min_mireds(self) -> int: """Return the coldest color_temp that this cluster handler supports.""" - min_mireds = self.cluster.get("color_temp_physical_min", self.MIN_MIREDS) + min_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_min.name, self.MIN_MIREDS + ) if min_mireds == 0: self.warning( ( @@ -111,7 +130,9 @@ class ColorClusterHandler(ClusterHandler): @property def max_mireds(self) -> int: """Return the warmest color_temp that this cluster handler supports.""" - max_mireds = self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) + max_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_max.name, self.MAX_MIREDS + ) if max_mireds == 0: self.warning( ( @@ -128,8 +149,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports hue and saturation.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Hue_and_saturation - in self.color_capabilities + and Color.ColorCapabilities.Hue_and_saturation in self.color_capabilities ) @property @@ -137,7 +157,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports enhanced hue and saturation.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Enhanced_hue in self.color_capabilities + and Color.ColorCapabilities.Enhanced_hue in self.color_capabilities ) @property @@ -145,8 +165,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports xy.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.XY_attributes - in self.color_capabilities + and Color.ColorCapabilities.XY_attributes in self.color_capabilities ) @property @@ -154,8 +173,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports color temperature.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Color_temperature - in self.color_capabilities + and Color.ColorCapabilities.Color_temperature in self.color_capabilities ) or self.color_temperature is not None @property @@ -163,15 +181,15 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports color loop.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities + and Color.ColorCapabilities.Color_loop in self.color_capabilities ) @property - def options(self) -> lighting.Color.Options: + def options(self) -> Color.Options: """Return ZCL options of the cluster handler.""" - return lighting.Color.Options(self.cluster.get("options", 0)) + return Color.Options(self.cluster.get(Color.AttributeDefs.options.name, 0)) @property def execute_if_off_supported(self) -> bool: """Return True if the cluster handler can execute commands when off.""" - return lighting.Color.Options.Execute_if_off in self.options + return Color.Options.Execute_if_off in self.options diff --git a/homeassistant/components/zha/core/cluster_handlers/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py index bac4d8c09a9..e2ed36bdc83 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -2,16 +2,16 @@ import asyncio import zigpy.exceptions -from zigpy.zcl.clusters import lightlink +from zigpy.zcl.clusters.lightlink import LightLink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand from .. import registries from . import ClusterHandler, ClusterHandlerStatus -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lightlink.LightLink.cluster_id) -class LightLink(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(LightLink.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LightLink.cluster_id) +class LightLinkClusterHandler(ClusterHandler): """Lightlink cluster handler.""" BIND: bool = False diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 57f1e2ee304..608a256606f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -25,7 +25,7 @@ from ..const import ( UNKNOWN, ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -from .general import MultistateInput +from .general import MultistateInputClusterHandler if TYPE_CHECKING: from ..endpoint import Endpoint @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_HUMIDITY_CLUSTER ) -class SmartThingsHumidity(ClusterHandler): +class SmartThingsHumidityClusterHandler(ClusterHandler): """Smart Things Humidity cluster handler.""" REPORT_CONFIG = ( @@ -49,7 +49,7 @@ class SmartThingsHumidity(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) -class OsramButton(ClusterHandler): +class OsramButtonClusterHandler(ClusterHandler): """Osram button cluster handler.""" REPORT_CONFIG = () @@ -57,7 +57,7 @@ class OsramButton(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) -class PhillipsRemote(ClusterHandler): +class PhillipsRemoteClusterHandler(ClusterHandler): """Phillips remote cluster handler.""" REPORT_CONFIG = () @@ -84,7 +84,7 @@ class TuyaClusterHandler(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) -class OppleRemote(ClusterHandler): +class OppleRemoteClusterHandler(ClusterHandler): """Opple cluster handler.""" REPORT_CONFIG = () @@ -160,6 +160,14 @@ class OppleRemote(ClusterHandler): "startup_on_off": True, "decoupled_mode": True, } + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = { + "hooks_state": True, + "hooks_lock": True, + "positions_stored": True, + "light_level": True, + "hand_open": True, + } async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Initialize cluster handler specific.""" @@ -173,7 +181,7 @@ class OppleRemote(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) -class SmartThingsAcceleration(ClusterHandler): +class SmartThingsAccelerationClusterHandler(ClusterHandler): """Smart Things Acceleration cluster handler.""" REPORT_CONFIG = ( @@ -220,7 +228,7 @@ class SmartThingsAcceleration(ClusterHandler): @registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) -class InovelliNotificationClusterHandler(ClientClusterHandler): +class InovelliNotificationClientClusterHandler(ClientClusterHandler): """Inovelli Notification cluster handler.""" @callback @@ -412,7 +420,7 @@ class IkeaAirPurifierClusterHandler(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) -class IkeaRemote(ClusterHandler): +class IkeaRemoteClusterHandler(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () @@ -421,5 +429,17 @@ class IkeaRemote(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 ) -class XiaomiVibrationAQ1ClusterHandler(MultistateInput): +class XiaomiVibrationAQ1ClusterHandler(MultistateInputClusterHandler): """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC11) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC11) +class SonoffPresenceSenorClusterHandler(ClusterHandler): + """SonoffPresenceSensor cluster handler.""" + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize SonoffPresenceSensor cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "SNZB-06P": + self.ZCL_INIT_ATTRS = {"last_illumination_state": True} diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index bd483920842..be079328228 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -4,7 +4,21 @@ from __future__ import annotations from typing import TYPE_CHECKING import zigpy.zcl -from zigpy.zcl.clusters import measurement +from zigpy.zcl.clusters.measurement import ( + PM25, + CarbonDioxideConcentration, + CarbonMonoxideConcentration, + FlowMeasurement, + FormaldehydeConcentration, + IlluminanceLevelSensing, + IlluminanceMeasurement, + LeafWetness, + OccupancySensing, + PressureMeasurement, + RelativeHumidity, + SoilMoisture, + TemperatureMeasurement, +) from .. import registries from ..const import ( @@ -14,53 +28,57 @@ from ..const import ( REPORT_CONFIG_MIN_INT, ) from . import AttrReportConfig, ClusterHandler -from .helpers import is_hue_motion_sensor +from .helpers import is_hue_motion_sensor, is_sonoff_presence_sensor if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.FlowMeasurement.cluster_id -) -class FlowMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(FlowMeasurement.cluster_id) +class FlowMeasurementClusterHandler(ClusterHandler): """Flow Measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=FlowMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.IlluminanceLevelSensing.cluster_id -) -class IlluminanceLevelSensing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceLevelSensing.cluster_id) +class IlluminanceLevelSensingClusterHandler(ClusterHandler): """Illuminance Level Sensing cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=IlluminanceLevelSensing.AttributeDefs.level_status.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.IlluminanceMeasurement.cluster_id -) -class IlluminanceMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceMeasurement.cluster_id) +class IlluminanceMeasurementClusterHandler(ClusterHandler): """Illuminance Measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=IlluminanceMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.OccupancySensing.cluster_id -) -class OccupancySensing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OccupancySensing.cluster_id) +class OccupancySensingClusterHandler(ClusterHandler): """Occupancy Sensing cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig( + attr=OccupancySensing.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_IMMEDIATE, + ), ) def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -69,122 +87,121 @@ class OccupancySensing(ClusterHandler): if is_hue_motion_sensor(self): self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["sensitivity"] = True + if is_sonoff_presence_sensor(self): + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["ultrasonic_o_to_u_delay"] = True + self.ZCL_INIT_ATTRS["ultrasonic_u_to_o_threshold"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.PressureMeasurement.cluster_id -) -class PressureMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PressureMeasurement.cluster_id) +class PressureMeasurementClusterHandler(ClusterHandler): """Pressure measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=PressureMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.RelativeHumidity.cluster_id -) -class RelativeHumidity(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RelativeHumidity.cluster_id) +class RelativeHumidityClusterHandler(ClusterHandler): """Relative Humidity measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=RelativeHumidity.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.SoilMoisture.cluster_id -) -class SoilMoisture(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(SoilMoisture.cluster_id) +class SoilMoistureClusterHandler(ClusterHandler): """Soil Moisture measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=SoilMoisture.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.LeafWetness.cluster_id) -class LeafWetness(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LeafWetness.cluster_id) +class LeafWetnessClusterHandler(ClusterHandler): """Leaf Wetness measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=LeafWetness.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.TemperatureMeasurement.cluster_id -) -class TemperatureMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(TemperatureMeasurement.cluster_id) +class TemperatureMeasurementClusterHandler(ClusterHandler): """Temperature measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=TemperatureMeasurement.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.CarbonMonoxideConcentration.cluster_id + CarbonMonoxideConcentration.cluster_id ) -class CarbonMonoxideConcentration(ClusterHandler): +class CarbonMonoxideConcentrationClusterHandler(ClusterHandler): """Carbon Monoxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=CarbonMonoxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.CarbonDioxideConcentration.cluster_id + CarbonDioxideConcentration.cluster_id ) -class CarbonDioxideConcentration(ClusterHandler): +class CarbonDioxideConcentrationClusterHandler(ClusterHandler): """Carbon Dioxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=CarbonDioxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.PM25.cluster_id) -class PM25(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PM25.cluster_id) +class PM25ClusterHandler(ClusterHandler): """Particulate Matter 2.5 microns or less measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=PM25.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.FormaldehydeConcentration.cluster_id + FormaldehydeConcentration.cluster_id ) -class FormaldehydeConcentration(ClusterHandler): +class FormaldehydeConcentrationClusterHandler(ClusterHandler): """Formaldehyde measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=FormaldehydeConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) diff --git a/homeassistant/components/zha/core/cluster_handlers/protocol.py b/homeassistant/components/zha/core/cluster_handlers/protocol.py index 1643fe031cd..14f01a55b6a 100644 --- a/homeassistant/components/zha/core/cluster_handlers/protocol.py +++ b/homeassistant/components/zha/core/cluster_handlers/protocol.py @@ -1,143 +1,128 @@ """Protocol cluster handlers module for Zigbee Home Automation.""" -from zigpy.zcl.clusters import protocol +from zigpy.zcl.clusters.protocol import ( + AnalogInputExtended, + AnalogInputRegular, + AnalogOutputExtended, + AnalogOutputRegular, + AnalogValueExtended, + AnalogValueRegular, + BacnetProtocolTunnel, + BinaryInputExtended, + BinaryInputRegular, + BinaryOutputExtended, + BinaryOutputRegular, + BinaryValueExtended, + BinaryValueRegular, + GenericTunnel, + MultistateInputExtended, + MultistateInputRegular, + MultistateOutputExtended, + MultistateOutputRegular, + MultistateValueExtended, + MultistateValueRegular, +) from .. import registries from . import ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogInputExtended.cluster_id -) -class AnalogInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputExtended.cluster_id) +class AnalogInputExtendedClusterHandler(ClusterHandler): """Analog Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogInputRegular.cluster_id -) -class AnalogInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputRegular.cluster_id) +class AnalogInputRegularClusterHandler(ClusterHandler): """Analog Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogOutputExtended.cluster_id -) -class AnalogOutputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputExtended.cluster_id) +class AnalogOutputExtendedClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogOutputRegular.cluster_id -) -class AnalogOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputRegular.cluster_id) +class AnalogOutputRegularClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogValueExtended.cluster_id -) -class AnalogValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueExtended.cluster_id) +class AnalogValueExtendedClusterHandler(ClusterHandler): """Analog Value Extended edition cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogValueRegular.cluster_id -) -class AnalogValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueRegular.cluster_id) +class AnalogValueRegularClusterHandler(ClusterHandler): """Analog Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BacnetProtocolTunnel.cluster_id -) -class BacnetProtocolTunnel(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BacnetProtocolTunnel.cluster_id) +class BacnetProtocolTunnelClusterHandler(ClusterHandler): """Bacnet Protocol Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryInputExtended.cluster_id -) -class BinaryInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputExtended.cluster_id) +class BinaryInputExtendedClusterHandler(ClusterHandler): """Binary Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryInputRegular.cluster_id -) -class BinaryInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputRegular.cluster_id) +class BinaryInputRegularClusterHandler(ClusterHandler): """Binary Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryOutputExtended.cluster_id -) -class BinaryOutputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputExtended.cluster_id) +class BinaryOutputExtendedClusterHandler(ClusterHandler): """Binary Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryOutputRegular.cluster_id -) -class BinaryOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputRegular.cluster_id) +class BinaryOutputRegularClusterHandler(ClusterHandler): """Binary Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryValueExtended.cluster_id -) -class BinaryValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueExtended.cluster_id) +class BinaryValueExtendedClusterHandler(ClusterHandler): """Binary Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryValueRegular.cluster_id -) -class BinaryValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueRegular.cluster_id) +class BinaryValueRegularClusterHandler(ClusterHandler): """Binary Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(protocol.GenericTunnel.cluster_id) -class GenericTunnel(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GenericTunnel.cluster_id) +class GenericTunnelClusterHandler(ClusterHandler): """Generic Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateInputExtended.cluster_id -) -class MultiStateInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputExtended.cluster_id) +class MultiStateInputExtendedClusterHandler(ClusterHandler): """Multistate Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateInputRegular.cluster_id -) -class MultiStateInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputRegular.cluster_id) +class MultiStateInputRegularClusterHandler(ClusterHandler): """Multistate Input Regular cluster handler.""" @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateOutputExtended.cluster_id + MultistateOutputExtended.cluster_id ) -class MultiStateOutputExtended(ClusterHandler): +class MultiStateOutputExtendedClusterHandler(ClusterHandler): """Multistate Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateOutputRegular.cluster_id -) -class MultiStateOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutputRegular.cluster_id) +class MultiStateOutputRegularClusterHandler(ClusterHandler): """Multistate Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateValueExtended.cluster_id -) -class MultiStateValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueExtended.cluster_id) +class MultiStateValueExtendedClusterHandler(ClusterHandler): """Multistate Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateValueRegular.cluster_id -) -class MultiStateValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueRegular.cluster_id) +class MultiStateValueRegularClusterHandler(ClusterHandler): """Multistate Value Regular cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index 9c74a14daa8..c37fdc43766 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -9,8 +9,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any import zigpy.zcl -from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasWd, IasZone from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -29,41 +28,28 @@ from . import ClusterHandler, ClusterHandlerStatus if TYPE_CHECKING: from ..endpoint import Endpoint -IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), -IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), -IAS_ACE_EMERGENCY = 0x0002 # ("emergency", (), False), -IAS_ACE_FIRE = 0x0003 # ("fire", (), False), -IAS_ACE_PANIC = 0x0004 # ("panic", (), False), -IAS_ACE_GET_ZONE_ID_MAP = 0x0005 # ("get_zone_id_map", (), False), -IAS_ACE_GET_ZONE_INFO = 0x0006 # ("get_zone_info", (t.uint8_t,), False), -IAS_ACE_GET_PANEL_STATUS = 0x0007 # ("get_panel_status", (), False), -IAS_ACE_GET_BYPASSED_ZONE_LIST = 0x0008 # ("get_bypassed_zone_list", (), False), -IAS_ACE_GET_ZONE_STATUS = ( - 0x0009 # ("get_zone_status", (t.uint8_t, t.uint8_t, t.Bool, t.bitmap16), False) -) -NAME = 0 SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) -class IasAce(ClusterHandler): +class IasAceClusterHandler(ClusterHandler): """IAS Ancillary Control Equipment cluster handler.""" def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize IAS Ancillary Control Equipment cluster handler.""" super().__init__(cluster, endpoint) self.command_map: dict[int, Callable[..., Any]] = { - IAS_ACE_ARM: self.arm, - IAS_ACE_BYPASS: self._bypass, - IAS_ACE_EMERGENCY: self._emergency, - IAS_ACE_FIRE: self._fire, - IAS_ACE_PANIC: self._panic, - IAS_ACE_GET_ZONE_ID_MAP: self._get_zone_id_map, - IAS_ACE_GET_ZONE_INFO: self._get_zone_info, - IAS_ACE_GET_PANEL_STATUS: self._send_panel_status_response, - IAS_ACE_GET_BYPASSED_ZONE_LIST: self._get_bypassed_zone_list, - IAS_ACE_GET_ZONE_STATUS: self._get_zone_status, + AceCluster.ServerCommandDefs.arm.id: self.arm, + AceCluster.ServerCommandDefs.bypass.id: self._bypass, + AceCluster.ServerCommandDefs.emergency.id: self._emergency, + AceCluster.ServerCommandDefs.fire.id: self._fire, + AceCluster.ServerCommandDefs.panic.id: self._panic, + AceCluster.ServerCommandDefs.get_zone_id_map.id: self._get_zone_id_map, + AceCluster.ServerCommandDefs.get_zone_info.id: self._get_zone_info, + AceCluster.ServerCommandDefs.get_panel_status.id: self._send_panel_status_response, + AceCluster.ServerCommandDefs.get_bypassed_zone_list.id: self._get_bypassed_zone_list, + AceCluster.ServerCommandDefs.get_zone_status.id: self._get_zone_status, } self.arm_map: dict[AceCluster.ArmMode, Callable[..., Any]] = { AceCluster.ArmMode.Disarm: self._disarm, @@ -95,7 +81,7 @@ class IasAce(ClusterHandler): mode = AceCluster.ArmMode(arm_mode) self.zha_send_event( - self._cluster.server_commands[IAS_ACE_ARM].name, + AceCluster.ServerCommandDefs.arm.name, { "arm_mode": mode.value, "arm_mode_description": mode.name, @@ -191,7 +177,7 @@ class IasAce(ClusterHandler): def _bypass(self, zone_list, code) -> None: """Handle the IAS ACE bypass command.""" self.zha_send_event( - self._cluster.server_commands[IAS_ACE_BYPASS].name, + AceCluster.ServerCommandDefs.bypass.name, {"zone_list": zone_list, "code": code}, ) @@ -249,16 +235,16 @@ class IasAce(ClusterHandler): """Handle the IAS ACE zone status command.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(security.IasWd.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(security.IasWd.cluster_id) -class IasWd(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IasWd.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasWd.cluster_id) +class IasWdClusterHandler(ClusterHandler): """IAS Warning Device cluster handler.""" @staticmethod def set_bit(destination_value, destination_bit, source_value, source_bit): """Set the specified bit in the value.""" - if IasWd.get_bit(source_value, source_bit): + if IasWdClusterHandler.get_bit(source_value, source_bit): return destination_value | (1 << destination_bit) return destination_value @@ -280,15 +266,15 @@ class IasWd(ClusterHandler): is currently active (warning in progress). """ value = 0 - value = IasWd.set_bit(value, 0, squawk_level, 0) - value = IasWd.set_bit(value, 1, squawk_level, 1) + value = IasWdClusterHandler.set_bit(value, 0, squawk_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, squawk_level, 1) - value = IasWd.set_bit(value, 3, strobe, 0) + value = IasWdClusterHandler.set_bit(value, 3, strobe, 0) - value = IasWd.set_bit(value, 4, mode, 0) - value = IasWd.set_bit(value, 5, mode, 1) - value = IasWd.set_bit(value, 6, mode, 2) - value = IasWd.set_bit(value, 7, mode, 3) + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) await self.squawk(value) @@ -317,15 +303,15 @@ class IasWd(ClusterHandler): and then turn OFF for 6/10ths of a second. """ value = 0 - value = IasWd.set_bit(value, 0, siren_level, 0) - value = IasWd.set_bit(value, 1, siren_level, 1) + value = IasWdClusterHandler.set_bit(value, 0, siren_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, siren_level, 1) - value = IasWd.set_bit(value, 2, strobe, 0) + value = IasWdClusterHandler.set_bit(value, 2, strobe, 0) - value = IasWd.set_bit(value, 4, mode, 0) - value = IasWd.set_bit(value, 5, mode, 1) - value = IasWd.set_bit(value, 6, mode, 2) - value = IasWd.set_bit(value, 7, mode, 3) + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) await self.start_warning( value, warning_duration, strobe_duty_cycle, strobe_intensity @@ -336,19 +322,23 @@ class IasWd(ClusterHandler): class IASZoneClusterHandler(ClusterHandler): """Cluster handler for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} + ZCL_INIT_ATTRS = { + IasZone.AttributeDefs.zone_status.name: False, + IasZone.AttributeDefs.zone_state.name: True, + IasZone.AttributeDefs.zone_type.name: True, + } @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" - if command_id == 0: + if command_id == IasZone.ClientCommandDefs.status_change_notification.id: zone_status = args[0] # update attribute cache with new zone status self.cluster.update_attribute( - IasZone.attributes_by_name["zone_status"].id, zone_status + IasZone.AttributeDefs.zone_status.id, zone_status ) self.debug("Updated alarm state: %s", zone_status) - elif command_id == 1: + elif command_id == IasZone.ClientCommandDefs.enroll.id: self.debug("Enroll requested") self._cluster.create_catching_task( self.enroll_response( @@ -358,7 +348,9 @@ class IASZoneClusterHandler(ClusterHandler): async def async_configure(self): """Configure IAS device.""" - await self.get_attribute_value("zone_type", from_cache=False) + await self.get_attribute_value( + IasZone.AttributeDefs.zone_type.name, from_cache=False + ) if self._endpoint.device.skip_configuration: self.debug("skipping IASZoneClusterHandler configuration") return @@ -369,7 +361,9 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe( + {IasZone.AttributeDefs.cie_addr.name: ieee} + ) self.debug( "wrote cie_addr: %s to '%s' cluster", str(ieee), @@ -396,10 +390,10 @@ class IASZoneClusterHandler(ClusterHandler): @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" - if attrid == IasZone.attributes_by_name["zone_status"].id: + if attrid == IasZone.AttributeDefs.zone_status.id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - "zone_status", + IasZone.AttributeDefs.zone_status.name, value, ) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 2ceaeaf1013..65a02a01e02 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -6,7 +6,20 @@ from functools import partialmethod from typing import TYPE_CHECKING import zigpy.zcl -from zigpy.zcl.clusters import smartenergy +from zigpy.zcl.clusters.smartenergy import ( + Calendar, + DeviceManagement, + Drlc, + EnergyManagement, + Events, + KeyEstablishment, + MduPairing, + Messaging, + Metering, + Prepayment, + Price, + Tunneling, +) from .. import registries from ..const import ( @@ -21,106 +34,162 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Calendar.cluster_id) -class Calendar(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Calendar.cluster_id) +class CalendarClusterHandler(ClusterHandler): """Calendar cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.DeviceManagement.cluster_id -) -class DeviceManagement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceManagement.cluster_id) +class DeviceManagementClusterHandler(ClusterHandler): """Device Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Drlc.cluster_id) -class Drlc(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Drlc.cluster_id) +class DrlcClusterHandler(ClusterHandler): """Demand Response and Load Control cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.EnergyManagement.cluster_id -) -class EnergyManagement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(EnergyManagement.cluster_id) +class EnergyManagementClusterHandler(ClusterHandler): """Energy Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Events.cluster_id) -class Events(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Events.cluster_id) +class EventsClusterHandler(ClusterHandler): """Event cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.KeyEstablishment.cluster_id -) -class KeyEstablishment(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(KeyEstablishment.cluster_id) +class KeyEstablishmentClusterHandler(ClusterHandler): """Key Establishment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.MduPairing.cluster_id) -class MduPairing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MduPairing.cluster_id) +class MduPairingClusterHandler(ClusterHandler): """Pairing cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Messaging.cluster_id) -class Messaging(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Messaging.cluster_id) +class MessagingClusterHandler(ClusterHandler): """Messaging cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Metering.cluster_id) -class Metering(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Metering.cluster_id) +class MeteringClusterHandler(ClusterHandler): """Metering cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="current_summ_delivered", config=REPORT_CONFIG_DEFAULT), AttrReportConfig( - attr="current_tier1_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.instantaneous_demand.name, + config=REPORT_CONFIG_OP, ), AttrReportConfig( - attr="current_tier2_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.current_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier3_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.current_tier1_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier4_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.current_tier2_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier5_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.current_tier3_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier6_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=Metering.AttributeDefs.current_tier4_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier5_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier6_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_summ_received.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.status.name, + config=REPORT_CONFIG_ASAP, ), - AttrReportConfig(attr="status", config=REPORT_CONFIG_ASAP), ) ZCL_INIT_ATTRS = { - "demand_formatting": True, - "divisor": True, - "metering_device_type": True, - "multiplier": True, - "summation_formatting": True, - "unit_of_measure": True, + Metering.AttributeDefs.demand_formatting.name: True, + Metering.AttributeDefs.divisor.name: True, + Metering.AttributeDefs.metering_device_type.name: True, + Metering.AttributeDefs.multiplier.name: True, + Metering.AttributeDefs.summation_formatting.name: True, + Metering.AttributeDefs.unit_of_measure.name: True, } + METERING_DEVICE_TYPES_ELECTRIC = { + 0, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 127, + 134, + 135, + 136, + 137, + 138, + 140, + 141, + 142, + } + METERING_DEVICE_TYPES_GAS = {1, 128} + METERING_DEVICE_TYPES_WATER = {2, 129} + METERING_DEVICE_TYPES_HEATING_COOLING = {3, 5, 6, 130, 132, 133} + metering_device_type = { 0: "Electric Metering", 1: "Gas Metering", 2: "Water Metering", - 3: "Thermal Metering", + 3: "Thermal Metering", # deprecated 4: "Pressure Metering", 5: "Heat Metering", 6: "Cooling Metering", + 7: "End Use Measurement Device (EUMD) for metering electric vehicle charging", + 8: "PV Generation Metering", + 9: "Wind Turbine Generation Metering", + 10: "Water Turbine Generation Metering", + 11: "Micro Generation Metering", + 12: "Solar Hot Water Generation Metering", + 13: "Electric Metering Element/Phase 1", + 14: "Electric Metering Element/Phase 2", + 15: "Electric Metering Element/Phase 3", + 127: "Mirrored Electric Metering", 128: "Mirrored Gas Metering", 129: "Mirrored Water Metering", - 130: "Mirrored Thermal Metering", + 130: "Mirrored Thermal Metering", # deprecated 131: "Mirrored Pressure Metering", 132: "Mirrored Heat Metering", 133: "Mirrored Cooling Metering", + 134: "Mirrored End Use Measurement Device (EUMD) for metering electric vehicle charging", + 135: "Mirrored PV Generation Metering", + 136: "Mirrored Wind Turbine Generation Metering", + 137: "Mirrored Water Turbine Generation Metering", + 138: "Mirrored Micro Generation Metering", + 139: "Mirrored Solar Hot Water Generation Metering", + 140: "Mirrored Electric Metering Element/Phase 1", + 141: "Mirrored Electric Metering Element/Phase 2", + 142: "Mirrored Electric Metering Element/Phase 3", } class DeviceStatusElectric(enum.IntFlag): - """Metering Device Status.""" + """Electric Metering Device Status.""" NO_ALARMS = 0 CHECK_METER = 1 @@ -132,6 +201,45 @@ class Metering(ClusterHandler): SERVICE_DISCONNECT = 64 RESERVED = 128 + class DeviceStatusGas(enum.IntFlag): + """Gas Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + NOT_DEFINED = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusWater(enum.IntFlag): + """Water Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + PIPE_EMPTY = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusHeatingCooling(enum.IntFlag): + """Heating and Cooling Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + TEMPERATURE_SENSOR = 8 + BURST_DETECT = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + class DeviceStatusDefault(enum.IntFlag): """Metering Device Status.""" @@ -152,12 +260,12 @@ class Metering(ClusterHandler): @property def divisor(self) -> int: """Return divisor for the value.""" - return self.cluster.get("divisor") or 1 + return self.cluster.get(Metering.AttributeDefs.divisor.name) or 1 @property def device_type(self) -> str | int | None: """Return metering device type.""" - dev_type = self.cluster.get("metering_device_type") + dev_type = self.cluster.get(Metering.AttributeDefs.metering_device_type.name) if dev_type is None: return None return self.metering_device_type.get(dev_type, dev_type) @@ -165,33 +273,42 @@ class Metering(ClusterHandler): @property def multiplier(self) -> int: """Return multiplier for the value.""" - return self.cluster.get("multiplier") or 1 + return self.cluster.get(Metering.AttributeDefs.multiplier.name) or 1 @property def status(self) -> int | None: """Return metering device status.""" - if (status := self.cluster.get("status")) is None: + if (status := self.cluster.get(Metering.AttributeDefs.status.name)) is None: return None - if self.cluster.get("metering_device_type") == 0: - # Electric metering device type + + metering_device_type = self.cluster.get( + Metering.AttributeDefs.metering_device_type.name + ) + if metering_device_type in self.METERING_DEVICE_TYPES_ELECTRIC: return self.DeviceStatusElectric(status) + if metering_device_type in self.METERING_DEVICE_TYPES_GAS: + return self.DeviceStatusGas(status) + if metering_device_type in self.METERING_DEVICE_TYPES_WATER: + return self.DeviceStatusWater(status) + if metering_device_type in self.METERING_DEVICE_TYPES_HEATING_COOLING: + return self.DeviceStatusHeatingCooling(status) return self.DeviceStatusDefault(status) @property def unit_of_measurement(self) -> int: """Return unit of measurement.""" - return self.cluster.get("unit_of_measure") + return self.cluster.get(Metering.AttributeDefs.unit_of_measure.name) async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( - "demand_formatting", 0xF9 + Metering.AttributeDefs.demand_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._format_spec = self.get_formatting(fmting) fmting = self.cluster.get( - "summation_formatting", 0xF9 + Metering.AttributeDefs.summation_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) @@ -255,16 +372,16 @@ class Metering(ClusterHandler): summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Prepayment.cluster_id) -class Prepayment(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Prepayment.cluster_id) +class PrepaymentClusterHandler(ClusterHandler): """Prepayment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Price.cluster_id) -class Price(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Price.cluster_id) +class PriceClusterHandler(ClusterHandler): """Price cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Tunneling.cluster_id) -class Tunneling(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Tunneling.cluster_id) +class TunnelingClusterHandler(ClusterHandler): """Tunneling cluster handler.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecbd347a621..cb0aa466046 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -89,6 +89,7 @@ CLUSTER_HANDLER_LEVEL = ATTR_LEVEL CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" CLUSTER_HANDLER_OCCUPANCY = "occupancy" CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_OTA = "ota" CLUSTER_HANDLER_POWER_CONFIGURATION = "power" CLUSTER_HANDLER_PRESSURE = "pressure" CLUSTER_HANDLER_SHADE = "shade" @@ -120,6 +121,7 @@ PLATFORMS = ( Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, ) CONF_ALARM_MASTER_CODE = "alarm_master_code" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 468e89fbbf0..dd5a39115ae 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -11,7 +11,7 @@ import time from typing import TYPE_CHECKING, Any, Self from zigpy import types -import zigpy.device +from zigpy.device import Device as ZigpyDevice import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks @@ -26,6 +26,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -124,22 +125,23 @@ class ZHADevice(LogMixin): zha_gateway: ZHAGateway, ) -> None: """Initialize the gateway.""" - self.hass = hass - self._zigpy_device = zigpy_device - self._zha_gateway = zha_gateway - self._available = False - self._available_signal = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" - self._checkins_missed_count = 0 + self.hass: HomeAssistant = hass + self._zigpy_device: ZigpyDevice = zigpy_device + self._zha_gateway: ZHAGateway = zha_gateway + self._available_signal: str = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" + self._checkins_missed_count: int = 0 self.unsubs: list[Callable[[], None]] = [] - self.quirk_applied = isinstance(self._zigpy_device, zigpy.quirks.CustomDevice) - self.quirk_class = ( + self.quirk_applied: bool = isinstance( + self._zigpy_device, zigpy.quirks.CustomDevice + ) + self.quirk_class: str = ( f"{self._zigpy_device.__class__.__module__}." f"{self._zigpy_device.__class__.__name__}" ) - self.quirk_id = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) + self.quirk_id: str | None = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) if self.is_mains_powered: - self.consider_unavailable_time = async_get_zha_config_value( + self.consider_unavailable_time: int = async_get_zha_config_value( self._zha_gateway.config_entry, ZHA_OPTIONS, CONF_CONSIDER_UNAVAILABLE_MAINS, @@ -152,7 +154,10 @@ class ZHADevice(LogMixin): CONF_CONSIDER_UNAVAILABLE_BATTERY, CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ) - + self._available: bool = self.is_coordinator or ( + self.last_seen is not None + and time.time() - self.last_seen < self.consider_unavailable_time + ) self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) self._power_config_ch: ClusterHandler | None = None self._identify_ch: ClusterHandler | None = None @@ -402,13 +407,21 @@ class ZHADevice(LogMixin): ATTR_MODEL: self.model, } + @property + def sw_version(self) -> str | None: + """Return the software version for this device.""" + device_registry = dr.async_get(self.hass) + reg_device: DeviceEntry | None = device_registry.async_get(self.device_id) + if reg_device is None: + return None + return reg_device.sw_version + @classmethod def new( cls, hass: HomeAssistant, zigpy_dev: zigpy.device.Device, gateway: ZHAGateway, - restored: bool = False, ) -> Self: """Create new device.""" zha_dev = cls(hass, zigpy_dev, gateway) diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 1944f632e9a..1fed2caab60 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -6,6 +6,8 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING, cast +from zigpy.zcl.clusters.general import Ota + from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -32,6 +34,7 @@ from .. import ( # noqa: F401 sensor, siren, switch, + update, ) from . import const as zha_const, registries as zha_regs @@ -233,10 +236,16 @@ class ProbeEndpoint: cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if config_diagnostic_entities: + cluster_handlers = list(endpoint.all_cluster_handlers.values()) + ota_handler_id = f"{endpoint.id}:0x{Ota.cluster_id:04x}" + if ota_handler_id in endpoint.client_cluster_handlers: + cluster_handlers.append( + endpoint.client_cluster_handlers[ota_handler_id] + ) matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( endpoint.device.manufacturer, endpoint.device.model, - list(endpoint.all_cluster_handlers.values()), + cluster_handlers, endpoint.device.quirk_id, ) else: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index cca8aa93e99..14fd329f1bc 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -223,7 +223,7 @@ class ZHAGateway: zha_data.gateway = self self.coordinator_zha_device = self._async_get_or_create_device( - self._find_coordinator_device(), restored=True + self._find_coordinator_device() ) self.async_load_devices() @@ -264,11 +264,10 @@ class ZHAGateway: """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) + zha_device = self._async_get_or_create_device(zigpy_device) delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) - zha_device.available = delta < zha_device.consider_unavailable_time delta_msg = f"{str(timedelta(seconds=delta))} ago" _LOGGER.debug( ( @@ -622,11 +621,11 @@ class ZHAGateway: @callback def _async_get_or_create_device( - self, zigpy_device: zigpy.device.Device, restored: bool = False + self, zigpy_device: zigpy.device.Device ) -> ZHADevice: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self.hass, zigpy_device, self, restored) + zha_device = ZHADevice.new(self.hass, zigpy_device, self) self._devices[zigpy_device.ieee] = zha_device device_registry = dr.async_get(self.hass) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 72d09d239e1..5506ffb8289 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -13,7 +13,7 @@ from dataclasses import dataclass import enum import logging import re -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar import voluptuous as vol import zigpy.exceptions @@ -33,10 +33,14 @@ from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA from .registries import BINDABLE_CLUSTERS if TYPE_CHECKING: + from .cluster_handlers import ClusterHandler from .device import ZHADevice from .gateway import ZHAGateway +_ClusterHandlerT = TypeVar("_ClusterHandlerT", bound="ClusterHandler") _T = TypeVar("_T") +_R = TypeVar("_R") +_P = ParamSpec("_P") _LOGGER = logging.getLogger(__name__) @@ -314,7 +318,7 @@ class LogMixin: return self.log(logging.ERROR, msg, *args, **kwargs) -def convert_install_code(value: str) -> bytes: +def convert_install_code(value: str) -> zigpy.types.KeyData: """Convert string to install code bytes and validate length.""" try: @@ -325,10 +329,11 @@ def convert_install_code(value: str) -> bytes: if len(code) != 18: # 16 byte code + 2 crc bytes raise vol.Invalid("invalid length of the install code") - if zigpy.util.convert_install_code(code) is None: + link_key = zigpy.util.convert_install_code(code) + if link_key is None: raise vol.Invalid("invalid install code") - return code + return link_key QR_CODES = ( @@ -356,13 +361,13 @@ QR_CODES = ( [0-9a-fA-F]{34} ([0-9a-fA-F]{16}) # IEEE address DLK - ([0-9a-fA-F]{36}) # install code + ([0-9a-fA-F]{36}|[0-9a-fA-F]{32}) # install code / link key $ """, ) -def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: +def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, zigpy.types.KeyData]: """Try to parse the QR code. if successful, return a tuple of a EUI64 address and install code. @@ -375,10 +380,16 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: ieee_hex = binascii.unhexlify(match[1]) ieee = zigpy.types.EUI64(ieee_hex[::-1]) + + # Bosch supplies (A) device specific link key (DSLK) or (A) install code + crc + if "RB01SG" in code_pattern and len(match[2]) == 32: + link_key_hex = binascii.unhexlify(match[2]) + link_key = zigpy.types.KeyData(link_key_hex) + return ieee, link_key install_code = match[2] # install_code sanity check - install_code = convert_install_code(install_code) - return ieee, install_code + link_key = convert_install_code(install_code) + return ieee, link_key raise vol.Invalid(f"couldn't convert qr code: {qr_code}") diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f36cbc13533..d94a2f907d1 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -4,8 +4,9 @@ from __future__ import annotations import asyncio import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster from zigpy.zcl.foundation import Status from homeassistant.components.cover import ( @@ -14,6 +15,7 @@ from homeassistant.components.cover import ( ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +31,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery +from .core.cluster_handlers.closures import WindowCoveringClusterHandler from .core.const import ( CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_LEVEL, @@ -70,40 +73,145 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) +WCAttrs = WindowCoveringCluster.AttributeDefs +WCT = WindowCoveringCluster.WindowCoveringType +WCCS = WindowCoveringCluster.ConfigStatus + +ZCL_TO_COVER_DEVICE_CLASS = { + WCT.Awning: CoverDeviceClass.AWNING, + WCT.Drapery: CoverDeviceClass.CURTAIN, + WCT.Projector_screen: CoverDeviceClass.SHADE, + WCT.Rollershade: CoverDeviceClass.SHADE, + WCT.Rollershade_two_motors: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior_two_motors: CoverDeviceClass.SHADE, + WCT.Shutter: CoverDeviceClass.SHUTTER, + WCT.Tilt_blind_tilt_only: CoverDeviceClass.BLIND, + WCT.Tilt_blind_tilt_and_lift: CoverDeviceClass.BLIND, +} + + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" _attr_translation_key: str = "cover" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Init this sensor.""" + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this cover.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) - self._current_position = None - self._tilt_position = None + cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) + assert cluster_handler + self._cover_cluster_handler: WindowCoveringClusterHandler = cast( + WindowCoveringClusterHandler, cluster_handler + ) + if self._cover_cluster_handler.window_covering_type: + self._attr_device_class: CoverDeviceClass | None = ( + ZCL_TO_COVER_DEVICE_CLASS.get( + self._cover_cluster_handler.window_covering_type + ) + ) + self._attr_supported_features: CoverEntityFeature = ( + self._determine_supported_features() + ) + self._target_lift_position: int | None = None + self._target_tilt_position: int | None = None + self._determine_initial_state() + + def _determine_supported_features(self) -> CoverEntityFeature: + """Determine the supported cover features.""" + supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + supported_features |= CoverEntityFeature.SET_TILT_POSITION + supported_features |= CoverEntityFeature.OPEN_TILT + supported_features |= CoverEntityFeature.CLOSE_TILT + supported_features |= CoverEntityFeature.STOP_TILT + return supported_features + + def _determine_initial_state(self) -> None: + """Determine the initial state of the cover.""" + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + self._determine_state( + self.current_cover_tilt_position, is_lift_update=False + ) + if ( + self._cover_cluster_handler.window_covering_type + == WCT.Tilt_blind_tilt_and_lift + ): + state = self._state + self._determine_state(self.current_cover_position) + if state == STATE_OPEN and self._state == STATE_CLOSED: + # let the tilt state override the lift state + self._state = STATE_OPEN + else: + self._determine_state(self.current_cover_position) + + def _determine_state(self, position_or_tilt, is_lift_update=True) -> None: + """Determine the state of the cover. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ + if is_lift_update: + target = self._target_lift_position + current = self.current_cover_position + else: + target = self._target_tilt_position + current = self.current_cover_tilt_position + + if position_or_tilt == 100: + self._state = STATE_CLOSED + return + if target is not None and target != current: + # we are mid transition and shouldn't update the state + return + self._state = STATE_OPEN async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" + """Run when the cover entity is about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_position + self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.zcl_attribute_updated ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - self._state = last_state.state - if "current_position" in last_state.attributes: - self._current_position = last_state.attributes["current_position"] - if "current_tilt_position" in last_state.attributes: - self._tilt_position = last_state.attributes[ - "current_tilt_position" - ] # first allocation activate tilt - @property def is_closed(self) -> bool | None: - """Return if the cover is closed.""" + """Return True if the cover is closed. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ if self.current_cover_position is None: return None return self.current_cover_position == 0 @@ -122,39 +230,45 @@ class ZhaCover(ZhaEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return the current position of ZHA cover. - None is unknown, 0 is closed, 100 is fully open. + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler """ - return self._current_position + return self._cover_cluster_handler.current_position_lift_percentage @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" - return self._tilt_position + return self._cover_cluster_handler.current_position_tilt_percentage @callback - def async_set_position(self, attr_id, attr_name, value): + def zcl_attribute_updated(self, attr_id, attr_name, value): """Handle position update from cluster handler.""" - _LOGGER.debug("setting position: %s %s %s", attr_id, attr_name, value) - if attr_name == "current_position_lift_percentage": - self._current_position = 100 - value - elif attr_name == "current_position_tilt_percentage": - self._tilt_position = 100 - value - - if self._current_position == 0: - self._state = STATE_CLOSED - elif self._current_position == 100: - self._state = STATE_OPEN + if attr_id in ( + WCAttrs.current_position_lift_percentage.id, + WCAttrs.current_position_tilt_percentage.id, + ): + value = ( + self.current_cover_position + if attr_id == WCAttrs.current_position_lift_percentage.id + else self.current_cover_tilt_position + ) + self._determine_state( + value, + is_lift_update=attr_id == WCAttrs.current_position_lift_percentage.id, + ) self.async_write_ha_state() @callback def async_update_state(self, state): - """Handle state update from cluster handler.""" - _LOGGER.debug("state=%s", state) + """Handle state update from HA operations below.""" + _LOGGER.debug("async_update_state=%s", state) self._state = state self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: - """Open the window cover.""" + """Open the cover.""" res = await self._cover_cluster_handler.up_open() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to open cover: {res[1]}") @@ -162,13 +276,14 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" + # 0 is open in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(0) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs: Any) -> None: - """Close the window cover.""" + """Close the cover.""" res = await self._cover_cluster_handler.down_close() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to close cover: {res[1]}") @@ -176,42 +291,63 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" + # 100 is closed in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(100) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the roller shutter to a specific position.""" - new_pos = kwargs[ATTR_POSITION] - res = await self._cover_cluster_handler.go_to_lift_percentage(100 - new_pos) + """Move the cover to a specific position.""" + self._target_lift_position = kwargs[ATTR_POSITION] + assert self._target_lift_position is not None + assert self.current_cover_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_lift_percentage( + 100 - self._target_lift_position + ) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to set cover position: {res[1]}") self.async_update_state( - STATE_CLOSING if new_pos < self._current_position else STATE_OPENING + STATE_CLOSING + if self._target_lift_position < self.current_cover_position + else STATE_OPENING ) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover til to a specific position.""" - new_pos = kwargs[ATTR_TILT_POSITION] - res = await self._cover_cluster_handler.go_to_tilt_percentage(100 - new_pos) + """Move the cover tilt to a specific position.""" + self._target_tilt_position = kwargs[ATTR_TILT_POSITION] + assert self._target_tilt_position is not None + assert self.current_cover_tilt_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_tilt_percentage( + 100 - self._target_tilt_position + ) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") self.async_update_state( - STATE_CLOSING if new_pos < self._tilt_position else STATE_OPENING + STATE_CLOSING + if self._target_tilt_position < self.current_cover_tilt_position + else STATE_OPENING ) async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the window cover.""" + """Stop the cover.""" res = await self._cover_cluster_handler.stop() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to stop cover: {res[1]}") - self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED + self._target_lift_position = self.current_cover_position + self._determine_state(self.current_cover_position) self.async_write_ha_state() async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" - await self.async_stop_cover() + res = await self._cover_cluster_handler.stop() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + self._target_tilt_position = self.current_cover_tilt_position + self._determine_state(self.current_cover_tilt_position, is_lift_update=False) + self.async_write_ha_state() @MULTI_MATCH( diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 486b043b450..84399f3da32 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -8,7 +8,7 @@ import functools import itertools import logging import random -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -1183,7 +1183,9 @@ class LightGroup(BaseLight, ZhaGroupEntity): if self._zha_config_group_members_assume_state: self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY self._zha_config_enhanced_light_transition = False - self._attr_color_mode = None + + self._attr_color_mode = ColorMode.UNKNOWN + self._attr_supported_color_modes = set() # remove this when all ZHA platforms and base entities are updated @property @@ -1283,7 +1285,6 @@ class LightGroup(BaseLight, ZhaGroupEntity): effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] - self._attr_color_mode = None all_color_modes = list( helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) ) @@ -1301,14 +1302,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): ): # switch to XY if all members do not support HS self._attr_color_mode = ColorMode.XY - self._attr_supported_color_modes = None - all_supported_color_modes = list( + all_supported_color_modes: list[set[ColorMode]] = list( helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. - self._attr_supported_color_modes = cast( - set[str], set().union(*all_supported_color_modes) + self._attr_supported_color_modes = filter_supported_color_modes( + set().union(*all_supported_color_modes) ) self._attr_supported_features = LightEntityFeature(0) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 024fea9227a..e9ab98fa6bf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.6", + "bellows==0.38.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.109", - "zigpy-deconz==0.22.4", - "zigpy==0.60.7", + "zha-quirks==0.0.111", + "zigpy-deconz==0.23.0", + "zigpy==0.62.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", - "universal-silabs-flasher==0.0.15", + "universal-silabs-flasher==0.0.18", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 24964d7a154..2b6a64edf69 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,6 +5,8 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.zcl.clusters.hvac import Thermostat + from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature @@ -20,6 +22,7 @@ from .core.const import ( CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -966,3 +969,87 @@ class ThermostatLocalTempCalibration(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_OCCUPANCY, models={"SNZB-06P"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): + """Configuration of Sonoff sensor presence detection timeout.""" + + _unique_id_suffix = "presence_detection_timeout" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: int = 15 + _attr_native_max_value: int = 60 + _attribute_name = "ultrasonic_o_to_u_delay" + _attr_translation_key: str = "presence_detection_timeout" + + _attr_mode: NumberMode = NumberMode.BOX + _attr_icon: str = "mdi:timer-edit" + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLTemperatureEntity(ZHANumberConfigurationEntity): + """Common entity class for ZCL temperature input.""" + + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_step: float = 0.01 + _attr_multiplier: float = 0.01 + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLHeatSetpointLimitEntity(ZCLTemperatureEntity): + """Min or max heat setpoint setting on thermostats.""" + + _attr_icon: str = "mdi:thermostat" + _attr_native_step: float = 0.5 + + _min_source = Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name + _max_source = Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + # The spec says 0x954D, which is a signed integer, therefore the value is in decimals + min_present_value = self._cluster_handler.cluster.get(self._min_source, -27315) + return min_present_value * self._attr_multiplier + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + max_present_value = self._cluster_handler.cluster.get(self._max_source, 0x7FFF) + return max_present_value * self._attr_multiplier + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Max heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "max_heat_setpoint_limit" + _attribute_name: str = "max_heat_setpoint_limit" + _attr_translation_key: str = "max_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _min_source = Thermostat.AttributeDefs.min_heat_setpoint_limit.name + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Min heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "min_heat_setpoint_limit" + _attribute_name: str = "min_heat_setpoint_limit" + _attr_translation_key: str = "min_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _max_source = Thermostat.AttributeDefs.max_heat_setpoint_limit.name diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 1c13779209d..3736858d599 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -25,6 +25,7 @@ from .core.const import ( CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -470,25 +471,6 @@ class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): _attr_translation_key: str = "decoupled_mode" -class AqaraE1ReverseDirection(types.enum8): - """Aqara curtain reversal.""" - - Normal = 0x00 - Inverted = 0x01 - - -@CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} -) -class AqaraCurtainMode(ZCLEnumSelectEntity): - """Representation of a ZHA curtain mode configuration entity.""" - - _unique_id_suffix = "window_covering_mode" - _attribute_name = "window_covering_mode" - _enum = AqaraE1ReverseDirection - _attr_translation_key: str = "window_covering_mode" - - class InovelliOutputMode(types.enum1): """Inovelli output mode.""" @@ -652,3 +634,48 @@ class AqaraThermostatPreset(ZCLEnumSelectEntity): _attribute_name = "preset" _enum = AqaraThermostatPresetMode _attr_translation_key: str = "preset" + + +class SonoffPresenceDetectionSensitivityEnum(types.enum8): + """Enum for detection sensitivity select entity.""" + + Low = 0x01 + Medium = 0x02 + High = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} +) +class SonoffPresenceDetectionSensitivity(ZCLEnumSelectEntity): + """Entity to set the detection sensitivity of the Sonoff SNZB-06P.""" + + _unique_id_suffix = "detection_sensitivity" + _attribute_name = "ultrasonic_u_to_o_threshold" + _enum = SonoffPresenceDetectionSensitivityEnum + _attr_translation_key: str = "detection_sensitivity" + + +class KeypadLockoutEnum(types.enum8): + """Keypad lockout options.""" + + Unlock = 0x00 + Lock1 = 0x01 + Lock2 = 0x02 + Lock3 = 0x03 + Lock4 = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="thermostat_ui") +class KeypadLockout(ZCLEnumSelectEntity): + """Mandatory attribute for thermostat_ui cluster. + + Often only the first two are implemented, and Lock2 to Lock4 should map to Lock1 in the firmware. + This however covers all bases. + """ + + _unique_id_suffix = "keypad_lockout" + _attribute_name: str = "keypad_lockout" + _enum = KeypadLockoutEnum + _attr_translation_key: str = "keypad_lockout" + _attr_icon: str = "mdi:lock" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 8bf8ca96d77..929ac803b10 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,6 +1,7 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import enum import functools @@ -9,11 +10,14 @@ import random from typing import TYPE_CHECKING, Any, Self from zigpy import types +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import Basic from homeassistant.components.climate import HVACAction from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -49,6 +53,7 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_ANALOG_INPUT, CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_DEVICE_TEMPERATURE, CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, CLUSTER_HANDLER_HUMIDITY, @@ -93,6 +98,9 @@ CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( ) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.SENSOR +) async def async_setup_entry( @@ -236,6 +244,19 @@ class PollableSensor(Sensor): ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class EnumSensor(Sensor): + """Sensor with value from enum.""" + + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM + _enum: type[enum.Enum] + + def formatter(self, value: int) -> str | None: + """Use name of enum.""" + assert self._enum is not None + return self._enum(value).name + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", @@ -317,7 +338,7 @@ class ElectricalMeasurement(PollableSensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement: str = UnitOfPower.WATT - _div_mul_prefix = "ac_power" + _div_mul_prefix: str | None = "ac_power" @property def extra_state_attributes(self) -> dict[str, Any]: @@ -340,10 +361,14 @@ class ElectricalMeasurement(PollableSensor): def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" - multiplier = getattr( - self._cluster_handler, f"{self._div_mul_prefix}_multiplier" - ) - divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") + if self._div_mul_prefix: + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") + else: + multiplier = self._multiplier + divisor = self._divisor value = float(value * multiplier) / divisor if value < 100 and divisor > 1: return round(value, self._decimals) @@ -417,13 +442,14 @@ class ElectricalMeasurementFrequency(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): - """Frequency measurement.""" + """Power Factor 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 + _div_mul_prefix = None @MULTI_MATCH( @@ -490,6 +516,15 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000))) +@dataclass(frozen=True, kw_only=True) +class SmartEnergyMeteringEntityDescription(SensorEntityDescription): + """Dataclass that describes a Zigbee smart energy metering entity.""" + + key: str = "instantaneous_demand" + state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT + scale: int = 1 + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, @@ -498,37 +533,88 @@ class Illuminance(Sensor): class SmartEnergyMetering(PollableSensor): """Metering sensor.""" + entity_description: SmartEnergyMeteringEntityDescription _use_custom_polling: bool = False _attribute_name = "instantaneous_demand" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER - _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "instantaneous_demand" - unit_of_measure_map = { - 0x00: UnitOfPower.WATT, - 0x01: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - 0x02: UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, - 0x03: f"100 {UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR}", - 0x04: f"US {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x05: f"IMP {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x06: UnitOfPower.BTU_PER_HOUR, - 0x07: f"l/{UnitOfTime.HOURS}", - 0x08: UnitOfPressure.KPA, # gauge - 0x09: UnitOfPressure.KPA, # absolute - 0x0A: f"1000 {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x0B: "unitless", - 0x0C: f"MJ/{UnitOfTime.SECONDS}", + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + 0x01: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + ), + 0x02: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + device_class=None, # volume flow rate is not supported yet + ), + 0x03: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + scale=100, + ), + 0x04: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # US gallons per hour + device_class=None, # volume flow rate is not supported yet + ), + 0x05: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # IMP gallons per hour + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.BTU_PER_HOUR, + device_class=None, + state_class=None, + ), + 0x07: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"l/{UnitOfTime.HOURS}", + device_class=None, # volume flow rate is not supported yet + ), + 0x08: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # gauge + 0x09: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # absolute + 0x0A: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.CUBIC_FEET}/{UnitOfTime.HOURS}", # cubic feet per hour + device_class=None, # volume flow rate is not supported yet + scale=1000, + ), + 0x0B: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfEnergy.MEGA_JOULE}/{UnitOfTime.SECONDS}", + device_class=None, # needs to be None as MJ/s is not supported + ), } + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + entity_description = self._ENTITY_DESCRIPTION_MAP.get( + self._cluster_handler.unit_of_measurement + ) + if entity_description is not None: + self.entity_description = entity_description + def formatter(self, value: int) -> int | float: """Pass through cluster handler formatter.""" return self._cluster_handler.demand_formatter(value) - @property - def native_unit_of_measurement(self) -> str | None: - """Return Unit of measurement.""" - return self.unit_of_measure_map.get(self._cluster_handler.unit_of_measurement) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" @@ -544,6 +630,23 @@ class SmartEnergyMetering(PollableSensor): attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] return attrs + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + state = super().native_value + if hasattr(self, "entity_description") and state is not None: + return float(state) * self.entity_description.scale + + return state + + +@dataclass(frozen=True, kw_only=True) +class SmartEnergySummationEntityDescription(SmartEnergyMeteringEntityDescription): + """Dataclass that describes a Zigbee smart energy summation entity.""" + + key: str = "summation_delivered" + state_class: SensorStateClass | None = SensorStateClass.TOTAL_INCREASING + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, @@ -553,26 +656,66 @@ class SmartEnergyMetering(PollableSensor): class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" + entity_description: SmartEnergySummationEntityDescription _attribute_name = "current_summ_delivered" _unique_id_suffix = "summation_delivered" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY - _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_translation_key: str = "summation_delivered" - unit_of_measure_map = { - 0x00: UnitOfEnergy.KILO_WATT_HOUR, - 0x01: UnitOfVolume.CUBIC_METERS, - 0x02: UnitOfVolume.CUBIC_FEET, - 0x03: f"100 {UnitOfVolume.CUBIC_FEET}", - 0x04: f"US {UnitOfVolume.GALLONS}", - 0x05: f"IMP {UnitOfVolume.GALLONS}", - 0x06: "BTU", - 0x07: UnitOfVolume.LITERS, - 0x08: UnitOfPressure.KPA, # gauge - 0x09: UnitOfPressure.KPA, # absolute - 0x0A: f"1000 {UnitOfVolume.CUBIC_FEET}", - 0x0B: "unitless", - 0x0C: "MJ", + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + 0x01: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x02: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + ), + 0x03: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=100, + ), + 0x04: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.GALLONS, # US gallons + device_class=SensorDeviceClass.VOLUME, + ), + 0x05: SmartEnergySummationEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}", + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergySummationEntityDescription( + native_unit_of_measurement="BTU", device_class=None, state_class=None + ), + 0x07: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x08: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # gauge + 0x09: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # absolute + 0x0A: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=1000, + ), + 0x0B: SmartEnergySummationEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.MEGA_JOULE, + device_class=SensorDeviceClass.ENERGY, + ), } def formatter(self, value: int) -> int | float: @@ -589,7 +732,7 @@ class SmartEnergySummation(SmartEnergyMetering): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"TS011F", "ZLinky_TIC"}, + models={"TS011F", "ZLinky_TIC", "TICMeter"}, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -601,7 +744,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier1SmartEnergySummation(PolledSmartEnergySummation): @@ -615,7 +758,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier2SmartEnergySummation(PolledSmartEnergySummation): @@ -629,7 +772,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier3SmartEnergySummation(PolledSmartEnergySummation): @@ -643,7 +786,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier4SmartEnergySummation(PolledSmartEnergySummation): @@ -657,7 +800,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier5SmartEnergySummation(PolledSmartEnergySummation): @@ -671,7 +814,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier6SmartEnergySummation(PolledSmartEnergySummation): @@ -683,6 +826,39 @@ class Tier6SmartEnergySummation(PolledSmartEnergySummation): _attr_translation_key: str = "tier6_summation_delivered" +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SmartEnergySummationReceived(PolledSmartEnergySummation): + """Smart Energy Metering summation received sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_summ_received" + _unique_id_suffix = "summation_received" + _attr_translation_key: str = "summation_received" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + This attribute only started to be initialized in HA 2024.2.0, + so the entity would still be created on the first HA start after the upgrade for existing devices, + as the initialization to see if an attribute is unsupported happens later in the background. + To avoid creating a lot of unnecessary entities for existing devices, + wait until the attribute was properly initialized once for now. + """ + if cluster_handlers[0].cluster.get(cls._attribute_name) is None: + return None + return super().create_entity(unique_id, zha_device, cluster_handlers, **kwargs) + + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Pressure(Sensor): @@ -1034,17 +1210,14 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSource(Sensor): +class AqaraPetFeederLastFeedingSource(EnumSensor): """Sensor that displays the last feeding source of pet feeder.""" _attribute_name = "last_feeding_source" _unique_id_suffix = "last_feeding_source" _attr_translation_key: str = "last_feeding_source" _attr_icon = "mdi:devices" - - def formatter(self, value: int) -> int | float | None: - """Numeric pass-through formatter.""" - return AqaraFeedingSource(value).name + _enum = AqaraFeedingSource @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) @@ -1095,3 +1268,116 @@ class AqaraSmokeDensityDbm(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_icon: str = "mdi:google-circles-communities" _attr_suggested_display_precision: int = 3 + + +class SonoffIlluminationStates(types.enum8): + """Enum for displaying last Illumination state.""" + + Dark = 0x00 + Light = 0x01 + + +@MULTI_MATCH(cluster_handler_names="sonoff_manufacturer", models={"SNZB-06P"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorIlluminationStatus(EnumSensor): + """Sensor that displays the illumination status the last time peresence was detected.""" + + _attribute_name = "last_illumination_state" + _unique_id_suffix = "last_illumination" + _attr_translation_key: str = "last_illumination_state" + _attr_icon: str = "mdi:theme-light-dark" + _enum = SonoffIlluminationStates + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PiHeatingDemand(Sensor): + """Sensor that displays the percentage of heating power demanded. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "pi_heating_demand" + _attribute_name = "pi_heating_demand" + _attr_translation_key: str = "pi_heating_demand" + _attr_icon: str = "mdi:radiator" + _attr_native_unit_of_measurement = PERCENTAGE + _decimals = 0 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +class SetpointChangeSourceEnum(types.enum8): + """The source of the setpoint change.""" + + Manual = 0x00 + Schedule = 0x01 + External = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SetpointChangeSource(EnumSensor): + """Sensor that displays the source of the setpoint change. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "setpoint_change_source" + _attribute_name = "setpoint_change_source" + _attr_translation_key: str = "setpoint_change_source" + _attr_icon: str = "mdi:thermostat" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _enum = SetpointChangeSourceEnum + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class WindowCoveringTypeSensor(EnumSensor): + """Sensor that displays the type of a cover device.""" + + _attribute_name: str = WindowCovering.AttributeDefs.window_covering_type.name + _enum = WindowCovering.WindowCoveringType + _unique_id_suffix: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_translation_key: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:curtains" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_BASIC, models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainMotorPowerSourceSensor(EnumSensor): + """Sensor that displays the power source of the Aqara E1 curtain motor device.""" + + _attribute_name: str = Basic.AttributeDefs.power_source.name + _enum = Basic.PowerSource + _unique_id_suffix: str = Basic.AttributeDefs.power_source.name + _attr_translation_key: str = Basic.AttributeDefs.power_source.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:battery-positive" + + +class AqaraE1HookState(types.enum8): + """Aqara hook state.""" + + Unlocked = 0x00 + Locked = 0x01 + Locking = 0x02 + Unlocking = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainHookStateSensor(EnumSensor): + """Representation of a ZHA curtain mode configuration entity.""" + + _attribute_name = "hooks_state" + _enum = AqaraE1HookState + _unique_id_suffix = "hooks_state" + _attr_translation_key: str = "hooks_state" + _attr_icon: str = "mdi:hook" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 86cadb62519..717eb2df033 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .core import discovery -from .core.cluster_handlers.security import IasWd +from .core.cluster_handlers.security import IasWdClusterHandler from .core.const import ( CLUSTER_HANDLER_IAS_WD, SIGNAL_ADD_ENTITIES, @@ -101,7 +101,9 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cluster_handler: IasWd = cast(IasWd, cluster_handlers[0]) + self._cluster_handler: IasWdClusterHandler = cast( + IasWdClusterHandler, cluster_handlers[0] + ) self._attr_is_on: bool = False self._off_listener: Callable[[], None] | None = None diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 8909af8a5ba..3db54712dee 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -328,7 +328,7 @@ }, "manufacturer": { "name": "Manufacturer", - "description": "Manufacturer code." + "description": "Manufacturer code. Use a value of \"-1\" to force no code to be set." } } }, @@ -566,6 +566,9 @@ }, "ias_zone": { "name": "IAS zone" + }, + "hand_open": { + "name": "Opened by hand" } }, "button": { @@ -727,6 +730,15 @@ }, "local_temperature_calibration": { "name": "Local temperature offset" + }, + "presence_detection_timeout": { + "name": "Presence detection timeout" + }, + "max_heat_setpoint_limit": { + "name": "Max heat setpoint limit" + }, + "min_heat_setpoint_limit": { + "name": "Min heat setpoint limit" } }, "select": { @@ -792,6 +804,12 @@ }, "decoupled_mode": { "name": "Decoupled mode" + }, + "detection_sensitivity": { + "name": "Detection Sensitivity" + }, + "keypad_lockout": { + "name": "Keypad lockout" } }, "sensor": { @@ -831,6 +849,9 @@ "tier6_summation_delivered": { "name": "Tier 6 summation delivered" }, + "summation_received": { + "name": "Summation received" + }, "device_temperature": { "name": "Device temperature" }, @@ -869,6 +890,24 @@ }, "smoke_density": { "name": "Smoke density" + }, + "last_illumination_state": { + "name": "Last illumination state" + }, + "pi_heating_demand": { + "name": "Pi heating demand" + }, + "setpoint_change_source": { + "name": "Setpoint change source" + }, + "power_source": { + "name": "Power source" + }, + "window_covering_type": { + "name": "Window covering type" + }, + "hooks_state": { + "name": "Hooks state" } }, "switch": { @@ -893,6 +932,12 @@ "invert_switch": { "name": "Invert switch" }, + "inverted": { + "name": "Inverted" + }, + "hooks_locked": { + "name": "Hooks locked" + }, "smart_bulb_mode": { "name": "Smart bulb mode" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index d4e835751f5..afc73baca70 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, SIGNAL_ADD_ENTITIES, @@ -588,3 +590,110 @@ class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): _attribute_name = "buzzer_manual_alarm" _attr_translation_key = "buzzer_manual_alarm" _attr_icon: str = "mdi:bullhorn" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls inversion for window covering devices. + + This is necessary because this cluster uses 2 attributes to control inversion. + """ + + _unique_id_suffix = "inverted" + _attribute_name = WindowCovering.AttributeDefs.config_status.name + _attr_translation_key = "inverted" + _attr_icon: str = "mdi:arrow-up-down" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + window_covering_mode_attr = ( + WindowCovering.AttributeDefs.window_covering_mode.name + ) + # this entity needs 2 attributes to function + if ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + or window_covering_mode_attr + in cluster_handler.cluster.unsupported_attributes + or window_covering_mode_attr + not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(window_covering_mode_attr) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + config_status = ConfigStatus( + self._cluster_handler.cluster.get(self._attribute_name) + ) + return ConfigStatus.Open_up_commands_reversed in config_status + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_on_off(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_on_off(False) + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + self.debug("Polling current state") + await self._cluster_handler.get_attributes( + [ + self._attribute_name, + WindowCovering.AttributeDefs.window_covering_mode.name, + ], + from_cache=False, + only_cache=False, + ) + self.async_write_ha_state() + + async def _async_on_off(self, invert: bool) -> None: + """Turn the entity on or off.""" + name: str = WindowCovering.AttributeDefs.window_covering_mode.name + current_mode: WindowCoveringMode = WindowCoveringMode( + self._cluster_handler.cluster.get(name) + ) + send_command: bool = False + if invert and WindowCoveringMode.Motor_direction_reversed not in current_mode: + current_mode |= WindowCoveringMode.Motor_direction_reversed + send_command = True + elif not invert and WindowCoveringMode.Motor_direction_reversed in current_mode: + current_mode &= ~WindowCoveringMode.Motor_direction_reversed + send_command = True + if send_command: + await self._cluster_handler.write_attributes_safe({name: current_mode}) + await self.async_update() + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorHooksLockedSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls whether the curtain motor hooks are locked.""" + + _unique_id_suffix = "hooks_lock" + _attribute_name = "hooks_lock" + _attr_translation_key = "hooks_locked" + _attr_icon: str = "mdi:lock" diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py new file mode 100644 index 00000000000..e92424acf47 --- /dev/null +++ b/homeassistant/components/zha/update.py @@ -0,0 +1,236 @@ +"""Representation of ZHA updates.""" +from __future__ import annotations + +from dataclasses import dataclass +import functools +from typing import TYPE_CHECKING, Any + +from zigpy.ota.image import BaseOTAImage +from zigpy.types import uint16_t +from zigpy.zcl.foundation import Status + +from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData + +from .core import discovery +from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, UNKNOWN +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE +) + +# don't let homeassistant check for updates button hammer the zigbee network +PARALLEL_UPDATES = 1 + + +@dataclass +class ZHAFirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for ZHA firmware update entity.""" + + image_type: uint16_t | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return {"image_type": self.image_type} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation update from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.UPDATE] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA) +class ZHAFirmwareUpdateEntity(ZhaEntity, UpdateEntity): + """Representation of a ZHA firmware update entity.""" + + _unique_id_suffix = "firmware_update" + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.SPECIFIC_VERSION + ) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Initialize the ZHA update entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._ota_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_OTA + ] + self._attr_installed_version: str = self.determine_installed_version() + self._image_type: uint16_t | None = None + self._latest_version_firmware: BaseOTAImage | None = None + self._result = None + + @callback + def determine_installed_version(self) -> str: + """Determine the currently installed firmware version.""" + currently_installed_version = self._ota_cluster_handler.current_file_version + version_from_dr = self.zha_device.sw_version + if currently_installed_version == UNKNOWN and version_from_dr: + currently_installed_version = version_from_dr + return currently_installed_version + + @property + def extra_restore_state_data(self) -> ZHAFirmwareUpdateExtraStoredData: + """Return ZHA firmware update specific state data to be restored.""" + return ZHAFirmwareUpdateExtraStoredData(self._image_type) + + @callback + def device_ota_update_available(self, image: BaseOTAImage) -> None: + """Handle ota update available signal from Zigpy.""" + self._latest_version_firmware = image + self._attr_latest_version = f"0x{image.header.file_version:08x}" + self._image_type = image.header.image_type + self._attr_installed_version = self.determine_installed_version() + self.async_write_ha_state() + + @callback + def _update_progress(self, current: int, total: int, progress: float) -> None: + """Update install progress on event.""" + assert self._latest_version_firmware + self._attr_in_progress = int(progress) + self.async_write_ha_state() + + @callback + def _reset_progress(self, write_state: bool = True) -> None: + """Reset update install progress.""" + self._result = None + self._attr_in_progress = False + if write_state: + self.async_write_ha_state() + + async def async_update(self) -> None: + """Handle the update entity service call to manually check for available firmware updates.""" + await super().async_update() + # check for updates in the HA settings menu can invoke this so we need to check if the device + # is mains powered so we don't get a ton of errors in the logs from sleepy devices. + if self.zha_device.available and self.zha_device.is_mains_powered: + await self._ota_cluster_handler.async_check_for_update() + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + firmware = self._latest_version_firmware + assert firmware + self._reset_progress(False) + self._attr_in_progress = True + self.async_write_ha_state() + + try: + self._result = await self.zha_device.device.update_firmware( + self._latest_version_firmware, + self._update_progress, + ) + except Exception as ex: + self._reset_progress() + raise HomeAssistantError(ex) from ex + + assert self._result is not None + + # If the update was not successful, we should throw an error to let the user know + if self._result != Status.SUCCESS: + # save result since reset_progress will clear it + results = self._result + self._reset_progress() + raise HomeAssistantError(f"Update was not successful - result: {results}") + + # If we get here, all files were installed successfully + self._attr_installed_version = ( + self._attr_latest_version + ) = f"0x{firmware.header.file_version:08x}" + self._latest_version_firmware = None + self._reset_progress() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + # If we have a complete previous state, use that to set the installed version + if ( + last_state + and self._attr_installed_version == UNKNOWN + and (installed_version := last_state.attributes.get(ATTR_INSTALLED_VERSION)) + ): + self._attr_installed_version = installed_version + # If we have a complete previous state, use that to set the latest version + if ( + last_state + and (latest_version := last_state.attributes.get(ATTR_LATEST_VERSION)) + is not None + and latest_version != UNKNOWN + ): + self._attr_latest_version = latest_version + # If we have no state or latest version to restore, or the latest version is + # the same as the installed version, we can set the latest + # version to installed so that the entity starts as off. + elif ( + not last_state + or not latest_version + or latest_version == self._attr_installed_version + ): + self._attr_latest_version = self._attr_installed_version + + if self._attr_latest_version != self._attr_installed_version and ( + extra_data := await self.async_get_last_extra_data() + ): + self._image_type = extra_data.as_dict()["image_type"] + if self._image_type: + self._latest_version_firmware = ( + await self.zha_device.device.application.ota.get_ota_image( + self.zha_device.manufacturer_code, self._image_type + ) + ) + # if we can't locate an image but we have a latest version that differs + # we should set the latest version to the installed version to avoid + # confusion and errors + if not self._latest_version_firmware: + self._attr_latest_version = self._attr_installed_version + + self.zha_device.device.add_listener(self) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed.""" + await super().async_will_remove_from_hass() + self._reset_progress(False) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 51941248f03..e3e67ea0e41 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -9,7 +9,7 @@ import voluptuous as vol import zigpy.backups from zigpy.config import CONF_DEVICE from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 +from zigpy.types.named import EUI64, KeyData from zigpy.zcl.clusters.security import IasAce import zigpy.zdo.types as zdo_types @@ -161,7 +161,9 @@ SERVICE_SCHEMAS = { vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( @@ -210,7 +212,9 @@ SERVICE_SCHEMAS = { vol.Required(ATTR_COMMAND_TYPE): cv.string, vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), cv.deprecated(ATTR_ARGS), @@ -223,7 +227,9 @@ SERVICE_SCHEMAS = { vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, vol.Required(ATTR_COMMAND): cv.positive_int, vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), } @@ -322,19 +328,19 @@ async def websocket_permit_devices( connection.subscriptions[msg["id"]] = async_cleanup zha_gateway.async_enable_debug_mode() src_ieee: EUI64 - code: bytes + link_key: KeyData if ATTR_SOURCE_IEEE in msg: src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + link_key = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + src_ieee, link_key = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) else: await zha_gateway.application_controller.permit(time_s=duration, node=ieee) @@ -819,8 +825,6 @@ async def websocket_read_zigbee_cluster_attributes( success = {} failure = {} if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code cluster = zha_device.async_get_cluster( endpoint_id, cluster_id, cluster_type=cluster_type ) @@ -1245,21 +1249,21 @@ def async_load_api(hass: HomeAssistant) -> None: duration: int = service.data[ATTR_DURATION] ieee: EUI64 | None = service.data.get(ATTR_IEEE) src_ieee: EUI64 - code: bytes + link_key: KeyData if ATTR_SOURCE_IEEE in service.data: src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + link_key = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) return if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + src_ieee, link_key = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) return @@ -1300,8 +1304,6 @@ def async_load_api(hass: HomeAssistant) -> None: zha_device = zha_gateway.get_device(ieee) response = None if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code response = await zha_device.write_zigbee_attribute( endpoint_id, cluster_id, diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index 1364dbe107a..fbada765cde 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -128,9 +128,13 @@ class ZhongHongClimate(ClimateEntity): ] _attr_should_poll = False _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hub, addr_out, addr_in): """Set up the ZhongHong climate devices.""" diff --git a/homeassistant/components/zodiac/__init__.py b/homeassistant/components/zodiac/__init__.py index 48d1d8aa7aa..1e7c1f3bc43 100644 --- a/homeassistant/components/zodiac/__init__.py +++ b/homeassistant/components/zodiac/__init__.py @@ -1,45 +1,8 @@ """The zodiac component.""" -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -CONFIG_SCHEMA = vol.Schema( - {vol.Optional(DOMAIN): {}}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the zodiac component.""" - if DOMAIN in config: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Zodiac", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - return True +from homeassistant.core import HomeAssistant async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/zodiac/config_flow.py b/homeassistant/components/zodiac/config_flow.py index ebc0a819d1d..4acb3873031 100644 --- a/homeassistant/components/zodiac/config_flow.py +++ b/homeassistant/components/zodiac/config_flow.py @@ -25,7 +25,3 @@ class ZodiacConfigFlow(ConfigFlow, domain=DOMAIN): 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]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index bfc9c2fce09..01ec041e9d8 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,9 +1,10 @@ """Support for the definition of zones.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable import logging from operator import attrgetter +import sys from typing import Any, Self, cast import voluptuous as vol @@ -109,40 +110,49 @@ def async_active_zone( This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones - min_dist = None - closest = None + min_dist: float = sys.maxsize + closest: State | None = None + # This can be called before async_setup by device tracker - zone_entity_ids: list[str] = hass.data.get(ZONE_ENTITY_IDS, []) + zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ()) + for entity_id in zone_entity_ids: - zone = hass.states.get(entity_id) if ( - not zone + not (zone := hass.states.get(entity_id)) + # Skip unavailable zones or zone.state == STATE_UNAVAILABLE - or zone.attributes.get(ATTR_PASSIVE) + # Skip passive zones + or (zone_attrs := zone.attributes).get(ATTR_PASSIVE) + # Skip zones where we cannot calculate distance + or ( + zone_dist := distance( + latitude, + longitude, + zone_attrs[ATTR_LATITUDE], + zone_attrs[ATTR_LONGITUDE], + ) + ) + is None + # Skip zone that are outside the radius aka the + # lat/long is outside the zone + or not (zone_dist - (radius := zone_attrs[ATTR_RADIUS]) < radius) ): continue - zone_dist = distance( - latitude, - longitude, - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], - ) - - if zone_dist is None: + # If have a closest and its not closer than the closest skip it + if closest and not ( + zone_dist < min_dist + or ( + # If same distance, prefer smaller zone + zone_dist == min_dist and radius < closest.attributes[ATTR_RADIUS] + ) + ): continue - within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] - closer_zone = closest is None or zone_dist < min_dist # type: ignore[unreachable] - smaller_zone = ( - zone_dist == min_dist - and zone.attributes[ATTR_RADIUS] - < cast(State, closest).attributes[ATTR_RADIUS] - ) - - if within_zone and (closer_zone or smaller_zone): - min_dist = zone_dist - closest = zone + # We got here which means it closer than the previous known closest + # or equal distance but this one is smaller. + min_dist = zone_dist + closest = zone return closest diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index ccadc452bc7..1321ef36f85 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -282,7 +282,8 @@ class DriverEvents: for node in controller.nodes.values() ] - # Devices that are in the device registry that are not known by the controller can be removed + # Devices that are in the device registry that are not known by the controller + # can be removed for device in stored_devices: if device not in known_devices: self.dev_reg.async_remove_device(device.id) @@ -509,25 +510,46 @@ class ControllerEvents: driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) - device = self.dev_reg.async_get_device(identifiers={device_id}) + node_id_device = self.dev_reg.async_get_device(identifiers={device_id}) via_device_id = None controller = driver.controller # Get the controller node device ID if this node is not the controller if controller.own_node and controller.own_node != node: via_device_id = get_device_id(driver, controller.own_node) - # Replace the device if it can be determined that this node is not the - # same product as it was previously. - if ( - device_id_ext - and device - and len(device.identifiers) == 2 - and device_id_ext not in device.identifiers - ): - self.remove_device(device) - device = None - if device_id_ext: + # If there is a device with this node ID but with a different hardware + # signature, remove the node ID based identifier from it. The hardware + # signature can be different for one of two reasons: 1) in the ideal + # scenario, the node was replaced with a different node that's a different + # device entirely, or 2) the device erroneously advertised the wrong + # hardware identifiers (this is known to happen due to poor RF conditions). + # While we would like to remove the old device automatically for case 1, we + # have no way to distinguish between these reasons so we leave it up to the + # user to remove the old device manually. + if ( + node_id_device + and len(node_id_device.identifiers) == 2 + and device_id_ext not in node_id_device.identifiers + ): + new_identifiers = node_id_device.identifiers.copy() + new_identifiers.remove(device_id) + self.dev_reg.async_update_device( + node_id_device.id, new_identifiers=new_identifiers + ) + # If there is an orphaned device that already exists with this hardware + # based identifier, add the node ID based identifier to the orphaned + # device. + if ( + hardware_device := self.dev_reg.async_get_device( + identifiers={device_id_ext} + ) + ) and len(hardware_device.identifiers) == 1: + new_identifiers = hardware_device.identifiers.copy() + new_identifiers.add(device_id) + self.dev_reg.async_update_device( + hardware_device.id, new_identifiers=new_identifiers + ) ids = {device_id, device_id_ext} else: ids = {device_id} @@ -769,9 +791,12 @@ class NodeEvents: return driver = self.controller_events.driver_events.driver - notification: EntryControlNotification | NotificationNotification | PowerLevelNotification | MultilevelSwitchNotification = event[ - "notification" - ] + notification: ( + EntryControlNotification + | NotificationNotification + | PowerLevelNotification + | MultilevelSwitchNotification + ) = event["notification"] device = self.dev_reg.async_get_device( identifiers={get_device_id(driver, notification.node)} ) @@ -984,6 +1009,39 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + entry_hass_data = hass.data[DOMAIN][config_entry.entry_id] + client: ZwaveClient = entry_hass_data[DATA_CLIENT] + + # Driver may not be ready yet so we can't allow users to remove a device since + # we need to check if the device is still known to the controller + if (driver := client.driver) is None: + LOGGER.error("Driver for %s is not ready", config_entry.title) + return False + + # If a node is found on the controller that matches the hardware based identifier + # on the device, prevent the device from being removed. + if next( + ( + node + for node in driver.controller.nodes.values() + if get_device_id_ext(driver, node) in device_entry.identifiers + ), + None, + ): + return False + + controller_events: ControllerEvents = entry_hass_data[ + DATA_DRIVER_EVENTS + ].controller_events + controller_events.registered_unique_ids.pop(device_entry.id, None) + controller_events.discovered_value_ids.pop(device_entry.id, None) + return True + + async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 7f4855bfbe5..8d14c8ed5b6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1693,7 +1693,7 @@ async def websocket_set_config_parameter( msg[ID], { VALUE_ID: zwave_value.value_id, - STATUS: cmd_status, + STATUS: cmd_status.status, }, ) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 33d1e6dfa63..876cf60b4cb 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -86,13 +86,13 @@ class ZWaveNodePingButton(ButtonEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.CONFIG _attr_has_entity_name = True + _attr_translation_key = "ping" def __init__(self, driver: Driver, node: ZwaveNode) -> None: """Initialize a ping Z-Wave device button entity.""" self.node = node # Entity class attributes - self._attr_name = "Ping" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.ping" # device may not be precreated in main handler yet diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index d511a030fb1..f5ad8ce36cd 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -37,10 +37,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity @@ -130,6 +129,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Representation of a Z-Wave climate.""" _attr_precision = PRECISION_TENTHS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo @@ -194,6 +194,16 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): self._set_modes_and_presets() if self._current_mode and len(self._hvac_presets) > 1: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + if HVACMode.OFF in self._hvac_modes: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + + # We can only support turn on if we are able to turn the device off, + # otherwise the device can be considered always on + if len(self._hvac_modes) == 2 or any( + mode in self._hvac_modes + for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL) + ): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON # If any setpoint value exists, we can assume temperature # can be set if any(self._setpoint_values.values()): @@ -243,11 +253,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # treat value as hvac mode if hass_mode := ZW_HVAC_MODE_MAP.get(mode_id): all_modes[hass_mode] = mode_id - # Dry and Fan modes are in the process of being migrated from - # presets to hvac modes. In the meantime, we will set them as - # both, presets and hvac modes, to maintain backwards compatibility - if mode_id in (ThermostatMode.DRY, ThermostatMode.FAN): - all_presets[mode_name] = mode_id else: # treat value as hvac preset all_presets[mode_name] = mode_id @@ -503,27 +508,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") - # Dry and Fan preset modes are deprecated as of Home Assistant 2023.8. - # Please use Dry and Fan HVAC modes instead. - if preset_mode_value in (ThermostatMode.DRY, ThermostatMode.FAN): - LOGGER.warning( - "Dry and Fan preset modes are deprecated and will be removed in Home " - "Assistant 2024.2. Please use the corresponding Dry and Fan HVAC " - "modes instead" - ) - async_create_issue( - self.hass, - DOMAIN, - f"dry_fan_presets_deprecation_{self.entity_id}", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="dry_fan_presets_deprecation", - translation_placeholders={ - "entity_id": self.entity_id, - }, - ) await self._async_set_value(self._current_mode, preset_mode_value) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 752e3545114..e252a2ad693 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -185,19 +185,23 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Install Z-Wave JS add-on.""" if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) + + if not self.install_task.done(): return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" + step_id="install_addon", + progress_action="install_addon", + progress_task=self.install_task, ) try: await self.install_task except AddonError as err: - self.install_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="install_failed") + finally: + self.install_task = None self.integration_created_addon = True - self.install_task = None return self.async_show_progress_done(next_step_id="configure_addon") @@ -213,18 +217,22 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Start Z-Wave JS add-on.""" if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) + + if not self.start_task.done(): return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" + step_id="start_addon", + progress_action="start_addon", + progress_task=self.start_task, ) try: await self.start_task except (CannotConnect, AddonError, AbortFlow) as err: - self.start_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -237,38 +245,32 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Start the Z-Wave JS add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) self.version_info = None - try: - if self.restart_addon: - await addon_manager.async_schedule_restart_addon() - else: - await addon_manager.async_schedule_start_addon() - # Sleep some seconds to let the add-on start properly before connecting. - for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): - await asyncio.sleep(ADDON_SETUP_TIMEOUT) - try: - if not self.ws_address: - discovery_info = await self._async_get_addon_discovery_info() - self.ws_address = ( - f"ws://{discovery_info['host']}:{discovery_info['port']}" - ) - self.version_info = await async_get_version_info( - self.hass, self.ws_address + if self.restart_addon: + await addon_manager.async_schedule_restart_addon() + else: + await addon_manager.async_schedule_start_addon() + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not self.ws_address: + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = ( + f"ws://{discovery_info['host']}:{discovery_info['port']}" ) - except (AbortFlow, CannotConnect) as err: - _LOGGER.debug( - "Add-on not ready yet, waiting %s seconds: %s", - ADDON_SETUP_TIMEOUT, - err, - ) - else: - break + self.version_info = await async_get_version_info( + self.hass, self.ws_address + ) + except (AbortFlow, CannotConnect) as err: + _LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) else: - raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) + break + else: + raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") @abstractmethod async def async_step_configure_addon( @@ -309,13 +311,7 @@ class BaseZwaveJSFlow(FlowHandler, ABC): async def _async_install_addon(self) -> None: """Install the Z-Wave JS add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_install_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) + await addon_manager.async_schedule_install_addon() async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index a211832039b..c8eb02ad6cb 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -25,7 +25,6 @@ from zwave_js_server.model.value import ( 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.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.group import expand_entity_ids from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json new file mode 100644 index 00000000000..2956cf2c6e0 --- /dev/null +++ b/homeassistant/components/zwave_js/icons.json @@ -0,0 +1,73 @@ +{ + "entity": { + "button": { + "ping": { + "default": "mdi:crosshairs-gps" + } + }, + "sensor": { + "can": { + "default": "mdi:car-brake-alert" + }, + "commands_dropped": { + "default": "mdi:trash-can" + }, + "controller_status": { + "default": "mdi:help-rhombus", + "state": { + "jammed": "mdi:lock", + "ready": "mdi:check", + "unresponsive": "mdi:bell-off" + } + }, + "last_seen": { + "default": "mdi:timer-sync" + }, + "messages_dropped": { + "default": "mdi:trash-can" + }, + "nak": { + "default": "mdi:hand-back-left-off" + }, + "node_status": { + "default": "mdi:help-rhombus", + "state": { + "alive": "mdi:heart-pulse", + "asleep": "mdi:sleep", + "awake": "mdi:eye", + "dead": "mdi:robot-dead", + "unknown": "mdi:help-rhombus" + } + }, + "successful_commands": { + "default": "mdi:check" + }, + "successful_messages": { + "default": "mdi:check" + }, + "timeout_ack": { + "default": "mdi:ear-hearing-off" + }, + "timeout_callback": { + "default": "mdi:timer-sand-empty" + }, + "timeout_response": { + "default": "mdi:timer-sand-empty" + } + } + }, + "services": { + "bulk_set_partial_config_parameters": "mdi:cogs", + "clear_lock_usercode": "mdi:eraser", + "invoke_cc_api": "mdi:api", + "multicast_set_value": "mdi:list-box", + "ping": "mdi:crosshairs-gps", + "refresh_notifications": "mdi:bell", + "refresh_value": "mdi:refresh", + "reset_meter": "mdi:meter-electric", + "set_config_parameter": "mdi:cog", + "set_lock_configuration": "mdi:shield-lock", + "set_lock_usercode": "mdi:lock-smart", + "set_value": "mdi:form-textbox" + } +} diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 8ba50c15e02..2b286240aa3 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -151,7 +151,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): add_to_watched_value_ids=False, ) - self._calculate_color_values() + self._calculate_color_support() if self._supports_rgbw: self._supported_color_modes.add(ColorMode.RGBW) elif self._supports_color: @@ -160,6 +160,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: self._supported_color_modes.add(ColorMode.BRIGHTNESS) + self._calculate_color_values() # Entity class attributes self.supports_brightness_transition = bool( @@ -374,8 +375,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self.async_write_ha_state() @callback - def _calculate_color_values(self) -> None: - """Calculate light colors.""" + def _get_color_values(self) -> tuple[Value | None, ...]: + """Get light colors.""" # NOTE: We lookup all values here (instead of relying on the multicolor one) # to find out what colors are supported # as this is a simple lookup by key, this not heavy @@ -404,6 +405,30 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE.value, ) + return (red_val, green_val, blue_val, ww_val, cw_val) + + @callback + def _calculate_color_support(self) -> None: + """Calculate light colors.""" + (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() + # RGB support + if red_val and green_val and blue_val: + self._supports_color = True + # color temperature support + if ww_val and cw_val: + self._supports_color_temp = True + # only one white channel (warm white) = rgbw support + elif red_val and green_val and blue_val and ww_val: + self._supports_rgbw = True + # only one white channel (cool white) = rgbw support + elif cw_val: + self._supports_rgbw = True + + @callback + def _calculate_color_values(self) -> None: + """Calculate light colors.""" + (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() + # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 combined_color_val = self.get_zwave_value( @@ -416,8 +441,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): else: multi_color = {} - # Default: Brightness (no color) - self._color_mode = ColorMode.BRIGHTNESS + # Default: Brightness (no color) or Unknown + if self.supported_color_modes == {ColorMode.BRIGHTNESS}: + self._color_mode = ColorMode.BRIGHTNESS + else: + self._color_mode = ColorMode.UNKNOWN # RGB support if red_val and green_val and blue_val: @@ -425,7 +453,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) - self._supports_color = True if None not in (red, green, blue): # convert to HS self._hs_color = color_util.color_RGB_to_hs(red, green, blue) @@ -434,7 +461,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # color temperature support if ww_val and cw_val: - self._supports_color_temp = True warm_white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites @@ -449,7 +475,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._color_temp = None # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: - self._supports_rgbw = True white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 83ee0523a3b..1010d9abd90 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -1,12 +1,12 @@ """Repairs for Z-Wave JS.""" from __future__ import annotations -import voluptuous as vol - from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from .const import DOMAIN from .helpers import async_get_node_from_device_id @@ -15,34 +15,44 @@ class DeviceConfigFileChangedFlow(RepairsFlow): def __init__(self, data: dict[str, str]) -> None: """Initialize.""" - self.device_name: str = data["device_name"] + self.description_placeholders: dict[str, str] = { + "device_name": data["device_name"] + } self.device_id: str = data["device_id"] async def async_step_init( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await self.async_step_confirm() + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) async def async_step_confirm( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - if user_input is not None: - try: - node = async_get_node_from_device_id(self.hass, self.device_id) - except ValueError: - return self.async_abort( - reason="cannot_connect", - description_placeholders={"device_name": self.device_name}, - ) - self.hass.async_create_task(node.async_refresh_info()) - return self.async_create_entry(title="", data={}) + try: + node = async_get_node_from_device_id(self.hass, self.device_id) + except ValueError: + return self.async_abort( + reason="cannot_connect", + description_placeholders=self.description_placeholders, + ) + self.hass.async_create_task(node.async_refresh_info()) + return self.async_create_entry(title="", data={}) - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders={"device_name": self.device_name}, + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"device_config_file_changed.{self.device_id}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, ) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 56ed3f010b8..0240725ca2d 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -1,12 +1,14 @@ """Representation of Z-Wave sensors.""" from __future__ import annotations -from collections.abc import Mapping -from typing import cast +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ControllerStatus, NodeStatus +from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, @@ -91,20 +93,6 @@ from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 -CONTROLLER_STATUS_ICON: dict[ControllerStatus, str] = { - ControllerStatus.READY: "mdi:check", - ControllerStatus.UNRESPONSIVE: "mdi:bell-off", - ControllerStatus.JAMMED: "mdi:lock", -} - -NODE_STATUS_ICON: dict[NodeStatus, str] = { - NodeStatus.ALIVE: "mdi:heart-pulse", - NodeStatus.ASLEEP: "mdi:sleep", - NodeStatus.AWAKE: "mdi:eye", - NodeStatus.DEAD: "mdi:robot-dead", - NodeStatus.UNKNOWN: "mdi:help-rhombus", -} - # These descriptions should include device class. ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ @@ -128,6 +116,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, ), ( ENTITY_DESC_KEY_VOLTAGE, @@ -339,131 +328,182 @@ ENTITY_DESCRIPTION_KEY_MAP = { } +def convert_dict_of_dicts( + statistics: ControllerStatisticsDataType | NodeStatisticsDataType, key: str +) -> Any: + """Convert a dictionary of dictionaries to a value.""" + keys = key.split(".") + return statistics.get(keys[0], {}).get(keys[1], {}).get(keys[2]) # type: ignore[attr-defined] + + +@dataclass(frozen=True, kw_only=True) +class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription): + """Class to represent a Z-Wave JS statistics sensor entity description.""" + + convert: Callable[ + [ControllerStatisticsDataType | NodeStatisticsDataType, str], Any + ] = lambda statistics, key: statistics.get(key) + entity_registry_enabled_default: bool = False + + # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesTX", - name="Successful messages (TX)", + translation_key="successful_messages", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesRX", - name="Successful messages (RX)", + translation_key="successful_messages", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedTX", - name="Messages dropped (TX)", + translation_key="messages_dropped", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedRX", - name="Messages dropped (RX)", + translation_key="messages_dropped", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( - key="NAK", - name="Messages not accepted", + ZWaveJSStatisticsSensorEntityDescription( + key="NAK", translation_key="nak", state_class=SensorStateClass.TOTAL + ), + ZWaveJSStatisticsSensorEntityDescription( + key="CAN", translation_key="can", state_class=SensorStateClass.TOTAL + ), + ZWaveJSStatisticsSensorEntityDescription( + key="timeoutACK", + translation_key="timeout_ack", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( - key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL - ), - SensorEntityDescription( - key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL - ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", - name="Timed out responses", + translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutCallback", - name="Timed out callbacks", + translation_key="timeout_callback", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.average", - name="Average background RSSI (channel 0)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.current", - name="Current background RSSI (channel 0)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.average", - name="Average background RSSI (channel 1)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.current", - name="Current background RSSI (channel 1)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.average", - name="Average background RSSI (channel 2)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.current", - name="Current background RSSI (channel 2)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), ] # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsRX", - name="Successful commands (RX)", + translation_key="successful_commands", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsTX", - name="Successful commands (TX)", + translation_key="successful_commands", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedRX", - name="Commands dropped (RX)", + translation_key="commands_dropped", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedTX", - name="Commands dropped (TX)", + translation_key="commands_dropped", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", - name="Timed out responses", + translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="rtt", - name="Round Trip Time", + translation_key="rtt", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), + ZWaveJSStatisticsSensorEntityDescription( + key="lastSeen", + translation_key="last_seen", + device_class=SensorDeviceClass.TIMESTAMP, + convert=( + lambda statistics, key: ( + datetime.fromisoformat(dt) # type: ignore[arg-type] + if (dt := statistics.get(key)) + else None + ) + ), + entity_registry_enabled_default=True, + ), ] @@ -661,7 +701,7 @@ class ZWaveNumericSensor(ZwaveSensor): """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): @@ -783,6 +823,7 @@ class ZWaveNodeStatusSensor(SensorEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True + _attr_translation_key = "node_status" def __init__( self, config_entry: ConfigEntry, driver: Driver, node: ZwaveNode @@ -792,7 +833,6 @@ class ZWaveNodeStatusSensor(SensorEntity): self.node = node # Entity class attributes - self._attr_name = "Node status" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.node_status" # device may not be precreated in main handler yet @@ -814,11 +854,6 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() - @property - def icon(self) -> str | None: - """Icon of the entity.""" - return NODE_STATUS_ICON[self.node.status] - async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. @@ -851,6 +886,7 @@ class ZWaveControllerStatusSensor(SensorEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True + _attr_translation_key = "controller_status" def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" @@ -860,7 +896,6 @@ class ZWaveControllerStatusSensor(SensorEntity): assert node # Entity class attributes - self._attr_name = "Status" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.controller_status" # device may not be precreated in main handler yet @@ -882,11 +917,6 @@ class ZWaveControllerStatusSensor(SensorEntity): self._attr_native_value = self.controller.status.name.lower() self.async_write_ha_state() - @property - def icon(self) -> str | None: - """Icon of the entity.""" - return CONTROLLER_STATUS_ICON[self.controller.status] - async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. @@ -913,9 +943,9 @@ class ZWaveControllerStatusSensor(SensorEntity): class ZWaveStatisticsSensor(SensorEntity): """Representation of a node/controller statistics sensor.""" + entity_description: ZWaveJSStatisticsSensorEntityDescription _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_entity_registry_enabled_default = False _attr_has_entity_name = True def __init__( @@ -923,7 +953,7 @@ class ZWaveStatisticsSensor(SensorEntity): config_entry: ConfigEntry, driver: Driver, statistics_src: ZwaveNode | Controller, - description: SensorEntityDescription, + description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" self.entity_description = description @@ -952,25 +982,11 @@ class ZWaveStatisticsSensor(SensorEntity): " service won't work for it" ) - def _get_data_from_statistics( - self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType - ) -> int | None: - """Get the data from the statistics dict.""" - if "." not in self.entity_description.key: - return cast(int | None, statistics.get(self.entity_description.key)) - - # If key contains dots, we need to traverse the dict to get to the right value - for key in self.entity_description.key.split("."): - if key not in statistics: - return None - statistics = statistics[key] # type: ignore[literal-required] - return cast(int, statistics) - @callback def statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" - self._attr_native_value = self._get_data_from_statistics( - event_data["statistics"] + self._attr_native_value = self.entity_description.convert( + event_data["statistics"], self.entity_description.key ) self.async_write_ha_state() @@ -995,6 +1011,6 @@ class ZWaveStatisticsSensor(SensorEntity): ) # Set initial state - self._attr_native_value = self._get_data_from_statistics( - self.statistics_src.statistics.data + self._attr_native_value = self.entity_description.convert( + self.statistics_src.statistics.data, self.entity_description.key ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 9b4f9827c1d..e8ef1df4b96 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -25,13 +25,13 @@ from zwave_js_server.util.node import ( 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 diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 19a47450080..9e2317ba728 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,428 +1,491 @@ { "config": { - "flow_title": "{name}", - "step": { - "manual": { - "data": { - "url": "[%key:common::config_flow::data::url%]" - } - }, - "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave JS add-on?" - }, - "on_supervisor": { - "title": "Select connection method", - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", - "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" - } - }, - "install_addon": { - "title": "The Z-Wave JS add-on installation has started" - }, - "configure_addon": { - "title": "Enter the Z-Wave JS add-on configuration", - "description": "The add-on will generate security keys if those fields are left empty.", - "data": { - "usb_path": "[%key:common::config_flow::data::usb_path%]", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", - "s2_access_control_key": "S2 Access Control Key" - } - }, - "start_addon": { - "title": "The Z-Wave JS add-on is starting." - }, - "hassio_confirm": { - "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" - }, - "zeroconf_confirm": { - "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", - "title": "Discovered Z-Wave JS Server" - } - }, - "error": { - "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", - "invalid_ws_url": "Invalid websocket URL", - "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%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", "addon_info_failed": "Failed to get Z-Wave JS add-on info.", "addon_install_failed": "Failed to install the Z-Wave JS add-on.", "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", "addon_start_failed": "Failed to start the Z-Wave JS add-on.", - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." }, + "error": { + "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ws_url": "Invalid websocket URL", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." - } - }, - "options": { + }, "step": { + "configure_addon": { + "data": { + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "The add-on will generate security keys if those fields are left empty.", + "title": "Enter the Z-Wave JS add-on configuration" + }, + "hassio_confirm": { + "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + }, + "install_addon": { + "title": "The Z-Wave JS add-on installation has started" + }, "manual": { "data": { "url": "[%key:common::config_flow::data::url%]" } }, "on_supervisor": { - "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]", - "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", "data": { - "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" - } - }, - "install_addon": { - "title": "[%key:component::zwave_js::config::step::install_addon::title%]" - }, - "configure_addon": { - "title": "[%key:component::zwave_js::config::step::configure_addon::title%]", - "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", - "data": { - "usb_path": "[%key:common::config_flow::data::usb_path%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", - "log_level": "Log level", - "emulate_hardware": "Emulate Hardware" - } + "use_addon": "Use the Z-Wave JS Supervisor add-on" + }, + "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "title": "Select connection method" }, "start_addon": { - "title": "[%key:component::zwave_js::config::step::start_addon::title%]" + "title": "The Z-Wave JS add-on is starting." + }, + "usb_confirm": { + "description": "Do you want to set up {name} with the Z-Wave JS add-on?" + }, + "zeroconf_confirm": { + "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", + "title": "Discovered Z-Wave JS Server" } - }, - "error": { - "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", - "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%]", - "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", - "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", - "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", - "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", - "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." - }, - "progress": { - "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", - "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "ping": "Ping device", + "refresh_value": "Refresh the value(s) for {entity_name}", + "reset_meter": "Reset meters on {subtype}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_value": "Set value of a Z-Wave Value" + }, + "condition_type": { + "config_parameter": "Config parameter {subtype} value", + "node_status": "Node status", + "value": "Current value of a Z-Wave Value" + }, "trigger_type": { "event.notification.entry_control": "Sent an Entry Control notification", "event.notification.notification": "Sent a notification", "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "state.node_status": "Node status changed", "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", - "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value", - "state.node_status": "Node status changed" + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + } + }, + "entity": { + "button": { + "ping": { + "name": "Ping" + } }, - "condition_type": { - "node_status": "Node status", - "config_parameter": "Config parameter {subtype} value", - "value": "Current value of a Z-Wave Value" - }, - "action_type": { - "clear_lock_usercode": "Clear usercode on {entity_name}", - "set_lock_usercode": "Set a usercode on {entity_name}", - "set_config_parameter": "Set value of config parameter {subtype}", - "set_value": "Set value of a Z-Wave Value", - "refresh_value": "Refresh the value(s) for {entity_name}", - "ping": "Ping device", - "reset_meter": "Reset meters on {subtype}" + "sensor": { + "average_background_rssi": { + "name": "Average background RSSI (channel {channel})" + }, + "can": { + "name": "Collisions" + }, + "commands_dropped": { + "name": "Commands dropped ({direction})" + }, + "controller_status": { + "name": "Status", + "state": { + "jammed": "Jammed", + "ready": "Ready", + "unresponsive": "Unresponsive" + } + }, + "current_background_rssi": { + "name": "Current background RSSI (channel {channel})" + }, + "last_seen": { + "name": "Last seen" + }, + "messages_dropped": { + "name": "Messages dropped ({direction})" + }, + "nak": { + "name": "Messages not accepted" + }, + "node_status": { + "name": "Node status", + "state": { + "alive": "Alive", + "asleep": "Asleep", + "awake": "Awake", + "dead": "Dead", + "unknown": "Unknown" + } + }, + "rssi": { + "name": "RSSI" + }, + "rtt": { + "name": "Round trip time" + }, + "successful_commands": { + "name": "Successful commands ({direction})" + }, + "successful_messages": { + "name": "Successful messages ({direction})" + }, + "timeout_ack": { + "name": "Missing ACKs" + }, + "timeout_callback": { + "name": "Timed out callbacks" + }, + "timeout_response": { + "name": "Timed out responses" + } } }, "issues": { - "invalid_server_version": { - "title": "Newer version of Z-Wave JS Server needed", - "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue." - }, - "dry_fan_presets_deprecation": { - "title": "Dry and Fan preset modes will be removed: {entity_id}", - "fix_flow": { - "step": { - "confirm": { - "title": "Dry and Fan preset modes will be removed: {entity_id}", - "description": "You are using the Dry or Fan preset modes in your entity `{entity_id}`.\n\nDry and Fan preset modes are deprecated and will be removed. Please update your automations to use the corresponding Dry and Fan **HVAC modes** instead.\n\nClick on SUBMIT below once you have manually fixed this issue." - } - } - } - }, "device_config_file_changed": { - "title": "Device configuration file changed: {device_name}", "fix_flow": { - "step": { - "confirm": { - "title": "Device configuration file changed: {device_name}", - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." - } - }, "abort": { - "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant." + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant.", + "issue_ignored": "Device config file update for {device_name} ignored." + }, + "step": { + "init": { + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "menu_options": { + "confirm": "Re-interview device", + "ignore": "Ignore device config update" + }, + "title": "Device configuration file changed: {device_name}" + } } + }, + "title": "Device configuration file changed: {device_name}" + }, + "invalid_server_version": { + "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.", + "title": "Newer version of Z-Wave JS Server needed" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", + "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", + "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", + "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emulate Hardware", + "log_level": "Log level", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", + "title": "[%key:component::zwave_js::config::step::configure_addon::title%]" + }, + "install_addon": { + "title": "[%key:component::zwave_js::config::step::install_addon::title%]" + }, + "manual": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "on_supervisor": { + "data": { + "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" + }, + "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", + "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" + }, + "start_addon": { + "title": "[%key:component::zwave_js::config::step::start_addon::title%]" } } }, "services": { - "clear_lock_usercode": { - "name": "Clear lock user code", - "description": "Clears a user code from a lock.", - "fields": { - "code_slot": { - "name": "Code slot", - "description": "Code slot to clear code from." - } - } - }, - "set_lock_usercode": { - "name": "Set lock user code", - "description": "Sets a user code on a lock.", - "fields": { - "code_slot": { - "name": "[%key:component::zwave_js::services::clear_lock_usercode::fields::code_slot::name%]", - "description": "Code slot to set the code." - }, - "usercode": { - "name": "Code", - "description": "Lock code to set." - } - } - }, - "set_config_parameter": { - "name": "Set device configuration parameter", - "description": "Changes the configuration parameters of your Z-Wave devices.", - "fields": { - "endpoint": { - "name": "Endpoint", - "description": "The configuration parameter's endpoint." - }, - "parameter": { - "name": "Parameter", - "description": "The name (or ID) of the configuration parameter you want to configure." - }, - "bitmask": { - "name": "Bitmask", - "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format." - }, - "value": { - "name": "Value", - "description": "The new value to set for this configuration parameter." - }, - "value_size": { - "name": "Value size", - "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." - }, - "value_format": { - "name": "Value format", - "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." - } - } - }, "bulk_set_partial_config_parameters": { - "name": "Bulk set partial configuration parameters (advanced).", "description": "Allows for bulk setting partial parameters. Useful when multiple partial parameters have to be set at the same time.", "fields": { "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::description%]" + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "parameter": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::name%]", - "description": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::description%]" + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::name%]" }, "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter." + "description": "The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" } - } + }, + "name": "Bulk set partial configuration parameters (advanced)." }, - "refresh_value": { - "name": "Refresh values", - "description": "Force updates the values of a Z-Wave entity.", + "clear_lock_usercode": { + "description": "Clears a user code from a lock.", "fields": { - "entity_id": { - "name": "Entities", - "description": "Entities to refresh." - }, - "refresh_all_values": { - "name": "Refresh all values?", - "description": "Whether to refresh all values (true) or just the primary value (false)." + "code_slot": { + "description": "Code slot to clear code from.", + "name": "Code slot" } - } - }, - "set_value": { - "name": "Set a value (advanced)", - "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing.", - "fields": { - "command_class": { - "name": "Command class", - "description": "The ID of the command class for the value." - }, - "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "The endpoint for the value." - }, - "property": { - "name": "Property", - "description": "The ID of the property for the value." - }, - "property_key": { - "name": "Property key", - "description": "The ID of the property key for the value." - }, - "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "The new value to set." - }, - "options": { - "name": "Options", - "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set." - }, - "wait_for_result": { - "name": "Wait for result?", - "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device." - } - } - }, - "multicast_set_value": { - "name": "Set a value on multiple devices via multicast (advanced)", - "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing.", - "fields": { - "broadcast": { - "name": "Broadcast?", - "description": "Whether command should be broadcast to all devices on the network." - }, - "command_class": { - "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]" - }, - "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::endpoint::description%]" - }, - "property": { - "name": "[%key:component::zwave_js::services::set_value::fields::property::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::property::description%]" - }, - "property_key": { - "name": "[%key:component::zwave_js::services::set_value::fields::property_key::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::property_key::description%]" - }, - "options": { - "name": "[%key:component::zwave_js::services::set_value::fields::options::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::options::description%]" - }, - "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::value::description%]" - } - } - }, - "ping": { - "name": "Ping a node", - "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep." - }, - "reset_meter": { - "name": "Reset meters on a node", - "description": "Resets the meters on a node.", - "fields": { - "meter_type": { - "name": "Meter type", - "description": "The type of meter to reset. Not all meters support the ability to pick a meter type to reset." - }, - "value": { - "name": "Target value", - "description": "The value that meters should be reset to. Not all meters support the ability to be reset to a specific value." - } - } + }, + "name": "Clear lock user code" }, "invoke_cc_api": { - "name": "Invoke a Command Class API on a node (advanced)", "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` service and require direct calls to the Command Class API.", "fields": { "command_class": { - "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", - "description": "The ID of the command class that you want to issue a command to." + "description": "The ID of the command class that you want to issue a command to.", + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]" }, "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted." + "description": "The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "method_name": { - "name": "Method name", - "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods." + "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", + "name": "Method name" }, "parameters": { - "name": "Parameters", - "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters." + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", + "name": "Parameters" } - } + }, + "name": "Invoke a Command Class API on a node (advanced)" + }, + "multicast_set_value": { + "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "broadcast": { + "description": "Whether command should be broadcast to all devices on the network.", + "name": "Broadcast?" + }, + "command_class": { + "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]" + }, + "endpoint": { + "description": "[%key:component::zwave_js::services::set_value::fields::endpoint::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" + }, + "options": { + "description": "[%key:component::zwave_js::services::set_value::fields::options::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::options::name%]" + }, + "property": { + "description": "[%key:component::zwave_js::services::set_value::fields::property::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::property::name%]" + }, + "property_key": { + "description": "[%key:component::zwave_js::services::set_value::fields::property_key::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::property_key::name%]" + }, + "value": { + "description": "[%key:component::zwave_js::services::set_value::fields::value::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" + } + }, + "name": "Set a value on multiple devices via multicast (advanced)" + }, + "ping": { + "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", + "name": "Ping a node" }, "refresh_notifications": { - "name": "Refresh notifications on a node (advanced)", "description": "Refreshes notifications on a node based on notification type and optionally notification event.", "fields": { - "notification_type": { - "name": "Notification Type", - "description": "The Notification Type number as defined in the Z-Wave specs." - }, "notification_event": { - "name": "Notification Event", - "description": "The Notification Event number as defined in the Z-Wave specs." + "description": "The Notification Event number as defined in the Z-Wave specs.", + "name": "Notification Event" + }, + "notification_type": { + "description": "The Notification Type number as defined in the Z-Wave specs.", + "name": "Notification Type" } - } + }, + "name": "Refresh notifications on a node (advanced)" + }, + "refresh_value": { + "description": "Force updates the values of a Z-Wave entity.", + "fields": { + "entity_id": { + "description": "Entities to refresh.", + "name": "Entities" + }, + "refresh_all_values": { + "description": "Whether to refresh all values (true) or just the primary value (false).", + "name": "Refresh all values?" + } + }, + "name": "Refresh values" + }, + "reset_meter": { + "description": "Resets the meters on a node.", + "fields": { + "meter_type": { + "description": "The type of meter to reset. Not all meters support the ability to pick a meter type to reset.", + "name": "Meter type" + }, + "value": { + "description": "The value that meters should be reset to. Not all meters support the ability to be reset to a specific value.", + "name": "Target value" + } + }, + "name": "Reset meters on a node" + }, + "set_config_parameter": { + "description": "Changes the configuration parameters of your Z-Wave devices.", + "fields": { + "bitmask": { + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.", + "name": "Bitmask" + }, + "endpoint": { + "description": "The configuration parameter's endpoint.", + "name": "Endpoint" + }, + "parameter": { + "description": "The name (or ID) of the configuration parameter you want to configure.", + "name": "Parameter" + }, + "value": { + "description": "The new value to set for this configuration parameter.", + "name": "Value" + }, + "value_format": { + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "name": "Value format" + }, + "value_size": { + "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "name": "Value size" + } + }, + "name": "Set device configuration parameter" }, "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." + "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`.", + "name": "Auto relock time" }, "block_to_block": { - "name": "Block to block", - "description": "Enable block-to-block functionality." + "description": "Enable block-to-block functionality.", + "name": "Block to block" + }, + "hold_and_release_time": { + "description": "Duration in seconds the latch stays retracted.", + "name": "Hold and release time" + }, + "inside_handles_can_open_door_configuration": { + "description": "A list of four booleans which indicate which inside handles can open the door.", + "name": "Inside handles can open door configuration" + }, + "lock_timeout": { + "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`.", + "name": "Lock timeout" + }, + "operation_type": { + "description": "The operation type of the lock.", + "name": "Operation Type" + }, + "outside_handles_can_open_door_configuration": { + "description": "A list of four booleans which indicate which outside handles can open the door.", + "name": "Outside handles can open door configuration" + }, + "twist_assist": { + "description": "Enable Twist Assist.", + "name": "Twist assist" } - } + }, + "name": "Set lock configuration" + }, + "set_lock_usercode": { + "description": "Sets a user code on a lock.", + "fields": { + "code_slot": { + "description": "Code slot to set the code.", + "name": "[%key:component::zwave_js::services::clear_lock_usercode::fields::code_slot::name%]" + }, + "usercode": { + "description": "Lock code to set.", + "name": "Code" + } + }, + "name": "Set lock user code" + }, + "set_value": { + "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "command_class": { + "description": "The ID of the command class for the value.", + "name": "Command class" + }, + "endpoint": { + "description": "The endpoint for the value.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" + }, + "options": { + "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.", + "name": "Options" + }, + "property": { + "description": "The ID of the property for the value.", + "name": "Property" + }, + "property_key": { + "description": "The ID of the property key for the value.", + "name": "Property key" + }, + "value": { + "description": "The new value to set.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" + }, + "wait_for_result": { + "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device.", + "name": "Wait for result?" + } + }, + "name": "Set a value (advanced)" } } } diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index cf743a3e85a..f3e60f925e6 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -191,7 +191,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): # If hass hasn't started yet, push the next update to the next day so that we # can preserve the offsets we've created between each node - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self._poll_unsub = async_call_later( self.hass, timedelta(days=1), self._async_update ) diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index 7d654311213..35e0d745619 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -56,6 +56,7 @@ class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 949774d3361..8a868018adf 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -67,6 +67,7 @@ from .requirements import RequirementsNotFound, async_get_integration_with_requi 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, YamlTypeError, load_yaml_dict +from .util.yaml.objects import NodeStrClass _LOGGER = logging.getLogger(__name__) @@ -1221,9 +1222,45 @@ async def async_process_component_and_handle_errors( integration_config_info = await async_process_component_config( hass, config, integration ) - return async_handle_component_errors( + async_handle_component_errors( hass, integration_config_info, integration, raise_on_failure ) + return async_drop_config_annotations(integration_config_info, integration) + + +@callback +def async_drop_config_annotations( + integration_config_info: IntegrationConfigInfo, + integration: Integration, +) -> ConfigType | None: + """Remove file and line annotations from str items in component configuration.""" + if (config := integration_config_info.config) is None: + return None + + def drop_config_annotations_rec(node: Any) -> Any: + if isinstance(node, dict): + # Some integrations store metadata in custom dict classes, preserve those + tmp = dict(node) + node.clear() + node.update( + (drop_config_annotations_rec(k), drop_config_annotations_rec(v)) + for k, v in tmp.items() + ) + return node + + if isinstance(node, list): + return [drop_config_annotations_rec(v) for v in node] + + if isinstance(node, NodeStrClass): + return str(node) + + return node + + # Don't drop annotations from the homeassistant integration because it may + # have configuration for other integrations as packages. + if integration.domain in config and integration.domain != CONF_CORE: + drop_config_annotations_rec(config[integration.domain]) + return config @callback @@ -1232,18 +1269,16 @@ def async_handle_component_errors( integration_config_info: IntegrationConfigInfo, integration: Integration, raise_on_failure: bool = False, -) -> ConfigType | None: +) -> 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 + return platform_exception: ConfigExceptionInfo domain = integration.domain @@ -1261,7 +1296,7 @@ def async_handle_component_errors( ) if not raise_on_failure: - return integration_config_info.config + return if len(config_exception_info) == 1: translation_key = platform_exception.translation_key diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 336261c3632..b0a8f952b1b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,7 +2,15 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping +from collections import UserDict +from collections.abc import ( + Callable, + Coroutine, + Generator, + Iterable, + Mapping, + ValuesView, +) from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -15,15 +23,23 @@ from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from . import data_entry_flow, loader from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform -from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback -from .data_entry_flow import FlowResult +from .core import ( + CALLBACK_TYPE, + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HassJob, + HomeAssistant, + callback, +) +from .data_entry_flow import FLOW_NOT_COMPLETE_STEPS, FlowResult from .exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, HomeAssistantError, ) -from .helpers import device_registry, entity_registry, storage +from .helpers import device_registry, entity_registry, issue_registry as ir, storage from .helpers.debounce import Debouncer from .helpers.dispatcher import async_dispatcher_send from .helpers.event import ( @@ -46,6 +62,7 @@ if TYPE_CHECKING: from .components.zeroconf import ZeroconfServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + _LOGGER = logging.getLogger(__name__) SOURCE_BLUETOOTH = "bluetooth" @@ -230,6 +247,7 @@ class ConfigEntry: "_integration_for_domain", "_tries", "_setup_again_job", + "_supports_options", ) def __init__( @@ -310,6 +328,9 @@ class ConfigEntry: # Supports remove device self.supports_remove_device: bool | None = None + # Supports options + self._supports_options: bool | None = None + # Listeners to call on update self.update_listeners: list[UpdateListenerType] = [] @@ -336,6 +357,21 @@ class ConfigEntry: self._tries = 0 self._setup_again_job: HassJob | None = None + def __repr__(self) -> str: + """Representation of ConfigEntry.""" + return ( + f"" + ) + + @property + def supports_options(self) -> bool: + """Return if entry supports config options.""" + if self._supports_options is None and (handler := HANDLERS.get(self.domain)): + # work out if handler has support for options flow + self._supports_options = handler.async_supports_options_flow(self) + return self._supports_options or False + async def async_setup( self, hass: HomeAssistant, @@ -455,7 +491,7 @@ class ConfigEntry: wait_time, ) - if hass.state == CoreState.running: + if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( hass, wait_time, self._async_get_setup_again_job(hass) ) @@ -478,6 +514,12 @@ class ConfigEntry: if self.domain != integration.domain: return + # + # It is important that this function does not yield to the + # event loop by using `await` or `async with` or similar until + # after the state has been set. Otherwise we risk that any `call_soon`s + # created by an integration will be executed before the state is set. + # if result: self._async_set_state(hass, ConfigEntryState.LOADED, None) else: @@ -759,7 +801,7 @@ class ConfigEntry: if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -770,6 +812,21 @@ class ConfigEntry: | (context or {}), data=self.data | (data or {}), ) + if result["type"] not in FLOW_NOT_COMPLETE_STEPS: + return + + # Create an issue, there's no need to hold the lock when doing that + issue_id = f"config_entry_reauth_{self.domain}_{self.entry_id}" + ir.async_create_issue( + hass, + HA_DOMAIN, + issue_id, + data={"flow_id": result["flow_id"]}, + is_fixable=False, + issue_domain=self.domain, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + ) @callback def async_get_active_flows( @@ -947,6 +1004,14 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): if not self._async_has_other_discovery_flows(flow.flow_id): persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None and ( + entry := self.config_entries.async_get_entry(entry_id) + ) is not None: + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result @@ -1051,6 +1116,67 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): ) +class ConfigEntryItems(UserDict[str, ConfigEntry]): + """Container for config items, maps config_entry_id -> entry. + + Maintains two additional indexes: + - domain -> list[ConfigEntry] + - domain -> unique_id -> ConfigEntry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._domain_index: dict[str, list[ConfigEntry]] = {} + self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} + + def values(self) -> ValuesView[ConfigEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: + """Add an item.""" + data = self.data + if entry_id in data: + # This is likely a bug in a test that is adding the same entry twice. + # In the future, once we have fixed the tests, this will raise HomeAssistantError. + _LOGGER.error("An entry with the id %s already exists", entry_id) + self._unindex_entry(entry_id) + data[entry_id] = entry + self._domain_index.setdefault(entry.domain, []).append(entry) + if entry.unique_id is not None: + self._domain_unique_id_index.setdefault(entry.domain, {})[ + entry.unique_id + ] = entry + + def _unindex_entry(self, entry_id: str) -> None: + """Unindex an entry.""" + entry = self.data[entry_id] + domain = entry.domain + self._domain_index[domain].remove(entry) + if not self._domain_index[domain]: + del self._domain_index[domain] + if (unique_id := entry.unique_id) is not None: + del self._domain_unique_id_index[domain][unique_id] + if not self._domain_unique_id_index[domain]: + del self._domain_unique_id_index[domain] + + def __delitem__(self, entry_id: str) -> None: + """Remove an item.""" + self._unindex_entry(entry_id) + super().__delitem__(entry_id) + + def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: + """Get entries for a domain.""" + return self._domain_index.get(domain, []) + + def get_entry_by_domain_and_unique_id( + self, domain: str, unique_id: str + ) -> ConfigEntry | None: + """Get entry by domain and unique id.""" + return self._domain_unique_id_index.get(domain, {}).get(unique_id) + + class ConfigEntries: """Manage the configuration entries. @@ -1063,8 +1189,7 @@ class ConfigEntries: self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) self._hass_config = hass_config - self._entries: dict[str, ConfigEntry] = {} - self._domain_index: dict[str, list[ConfigEntry]] = {} + self._entries = ConfigEntryItems() self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1087,23 +1212,29 @@ class ConfigEntries: @callback def async_get_entry(self, entry_id: str) -> ConfigEntry | None: """Return entry with matching entry_id.""" - return self._entries.get(entry_id) + return self._entries.data.get(entry_id) @callback def async_entries(self, domain: str | None = None) -> list[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return list(self._domain_index.get(domain, [])) + return list(self._entries.get_entries_for_domain(domain)) + + @callback + def async_entry_for_domain_unique_id( + self, domain: str, unique_id: str + ) -> ConfigEntry | None: + """Return entry for a domain with a matching unique id.""" + return self._entries.get_entry_by_domain_and_unique_id(domain, unique_id) async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" - if entry.entry_id in self._entries: + if entry.entry_id in self._entries.data: raise HomeAssistantError( f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry - self._domain_index.setdefault(entry.domain, []).append(entry) self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -1121,9 +1252,6 @@ class ConfigEntries: await entry.async_remove(self.hass) del self._entries[entry.entry_id] - self._domain_index[entry.domain].remove(entry) - if not self._domain_index[entry.domain]: - del self._domain_index[entry.domain] self._async_schedule_save() dev_reg = device_registry.async_get(self.hass) @@ -1133,12 +1261,15 @@ class ConfigEntries: ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should - # abort any reauth flow that is active for the removed entry. + # abort any reauth flow that is active for the removed entry and + # linked issues. for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} ): if "flow_id" in progress_flow: self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) # After we have fully removed an "ignore" config entry we can try and rediscover # it so that a user is able to immediately start configuring it. We do this by @@ -1183,13 +1314,10 @@ class ConfigEntries: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) if config is None: - self._entries = {} - self._domain_index = {} + self._entries = ConfigEntryItems() return - entries = {} - domain_index: dict[str, list[ConfigEntry]] = {} - + entries: ConfigEntryItems = ConfigEntryItems() for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1224,9 +1352,7 @@ class ConfigEntries: pref_disable_polling=entry.get("pref_disable_polling"), ) entries[entry_id] = config_entry - domain_index.setdefault(domain, []).append(config_entry) - self._domain_index = domain_index self._entries = entries async def async_setup(self, entry_id: str) -> bool: @@ -1359,8 +1485,15 @@ class ConfigEntries: """ changed = False + if unique_id is not UNDEFINED and entry.unique_id != unique_id: + # Reindex the entry if the unique_id has changed + entry_id = entry.entry_id + del self._entries[entry_id] + entry.unique_id = unique_id + self._entries[entry_id] = entry + changed = True + for attr, value in ( - ("unique_id", unique_id), ("title", title), ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), @@ -1573,38 +1706,41 @@ class ConfigFlow(data_entry_flow.FlowHandler): if self.unique_id is None: return - for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id != self.unique_id: - continue - should_reload = False - if ( - updates is not None - and self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - and reload_on_update - and entry.state - in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) - ): - # Existing config entry present, and the - # entry data just changed - should_reload = True - elif ( - self.source in DISCOVERY_SOURCES - and entry.state is ConfigEntryState.SETUP_RETRY - ): - # Existing config entry present in retry state, and we - # just discovered the unique id so we know its online - should_reload = True - # Allow ignored entries to be configured on manual user step - if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: - continue - if should_reload: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id), - f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", - ) - raise data_entry_flow.AbortFlow(error) + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + + should_reload = False + if ( + updates is not None + and self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + and reload_on_update + and entry.state in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) + ): + # Existing config entry present, and the + # entry data just changed + should_reload = True + elif ( + self.source in DISCOVERY_SOURCES + and entry.state is ConfigEntryState.SETUP_RETRY + ): + # Existing config entry present in retry state, and we + # just discovered the unique id so we know its online + should_reload = True + # Allow ignored entries to be configured on manual user step + if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: + return + if should_reload: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + raise data_entry_flow.AbortFlow(error) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True @@ -1633,11 +1769,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): ): self.hass.config_entries.flow.async_abort(progress["flow_id"]) - for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id == unique_id: - return entry - - return None + return self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, unique_id + ) @callback def _set_confirm_only( @@ -1850,6 +1984,32 @@ class ConfigFlow(data_entry_flow.FlowHandler): return result + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + reason: str = "reauth_successful", + ) -> data_entry_flow.FlowResult: + """Update config entry, reload config entry and finish config flow.""" + result = self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=unique_id, + title=title, + data=data, + options=options, + ) + if result: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + return self.async_abort(reason=reason) + class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 0f483da47d8..fb6e8ef896b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,14 +15,14 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "6" +MINOR_VERSION: Final = 2 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2024.4" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -251,6 +251,7 @@ CONF_SERVICE: Final = "service" CONF_SERVICE_DATA: Final = "data" CONF_SERVICE_DATA_TEMPLATE: Final = "data_template" CONF_SERVICE_TEMPLATE: Final = "service_template" +CONF_SET_CONVERSATION_RESPONSE: Final = "set_conversation_response" CONF_SHOW_ON_MAP: Final = "show_on_map" CONF_SLAVE: Final = "slave" CONF_SOURCE: Final = "source" @@ -540,6 +541,7 @@ ATTR_CONNECTIONS: Final = "connections" ATTR_DEFAULT_NAME: Final = "default_name" ATTR_MANUFACTURER: Final = "manufacturer" ATTR_MODEL: Final = "model" +ATTR_SERIAL_NUMBER: Final = "serial_number" ATTR_SUGGESTED_AREA: Final = "suggested_area" ATTR_SW_VERSION: Final = "sw_version" ATTR_HW_VERSION: Final = "hw_version" @@ -1040,7 +1042,9 @@ class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" CUBIC_METERS_PER_HOUR = "m³/h" - CUBIC_FEET_PER_MINUTE = "ft³/m" + CUBIC_FEET_PER_MINUTE = "ft³/min" + LITERS_PER_MINUTE = "L/min" + GALLONS_PER_MINUTE = "gal/min" _DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( diff --git a/homeassistant/core.py b/homeassistant/core.py index e843481f79d..4c59e88e840 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -46,7 +46,6 @@ import voluptuous as vol import yarl from . import block_async_io, util -from .backports.functools import cached_property from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -87,7 +86,7 @@ from .helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .helpers.json import json_dumps +from .helpers.json import json_bytes, json_fragment from .util import dt as dt_util, location from .util.async_ import ( cancelling, @@ -108,11 +107,14 @@ from .util.unit_system import ( # Typing imports that create a circular dependency if TYPE_CHECKING: + from functools import cached_property + from .auth import AuthManager from .components.http import ApiConfig, HomeAssistantHTTP from .config_entries import ConfigEntries from .helpers.entity import StateInfo - +else: + from .backports.functools import cached_property STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 STOP_STAGE_SHUTDOWN_TIMEOUT = 100 @@ -391,16 +393,23 @@ class HomeAssistant: self._stop_future: concurrent.futures.Future[None] | None = None self._shutdown_jobs: list[HassJobWithArgs] = [] - @property + @cached_property def is_running(self) -> bool: """Return if Home Assistant is running.""" return self.state in (CoreState.starting, CoreState.running) - @property + @cached_property def is_stopping(self) -> bool: """Return if Home Assistant is stopping.""" return self.state in (CoreState.stopping, CoreState.final_write) + def set_state(self, state: CoreState) -> None: + """Set the current state.""" + self.state = state + for prop in ("is_running", "is_stopping"): + with suppress(AttributeError): + delattr(self, prop) + def start(self) -> int: """Start Home Assistant. @@ -425,7 +434,7 @@ class HomeAssistant: This method is a coroutine. """ - if self.state != CoreState.not_running: + if self.state is not CoreState.not_running: raise RuntimeError("Home Assistant is already running") # _async_stop will set this instead of stopping the loop @@ -449,7 +458,7 @@ class HomeAssistant: _LOGGER.info("Starting Home Assistant") setattr(self.loop, "_thread_ident", threading.get_ident()) - self.state = CoreState.starting + self.set_state(CoreState.starting) self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -474,14 +483,14 @@ class HomeAssistant: # Allow automations to set up the start triggers before changing state await asyncio.sleep(0) - if self.state != CoreState.starting: + if self.state is not CoreState.starting: _LOGGER.warning( "Home Assistant startup has been interrupted. " "Its state may be inconsistent" ) return - self.state = CoreState.running + self.set_state(CoreState.running) self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -580,13 +589,13 @@ class HomeAssistant: # 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) @@ -685,7 +694,7 @@ class HomeAssistant: # 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) @@ -824,7 +833,7 @@ class HomeAssistant: def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" - if self.state == CoreState.not_running: # just ignore + if self.state is CoreState.not_running: # just ignore return # The future is never retrieved, and we only hold a reference # to it to prevent it from being garbage collected. @@ -844,12 +853,12 @@ class HomeAssistant: if not force: # Some tests require async_stop to run, # regardless of the state of the loop. - if self.state == CoreState.not_running: # just ignore + if self.state is CoreState.not_running: # just ignore return if self.state in [CoreState.stopping, CoreState.final_write]: _LOGGER.info("Additional call to async_stop was ignored") return - if self.state == CoreState.starting: + if self.state is CoreState.starting: # This may not work _LOGGER.warning( "Stopping Home Assistant before startup has completed may fail" @@ -892,7 +901,7 @@ class HomeAssistant: self.exit_code = exit_code - self.state = CoreState.stopping + self.set_state(CoreState.stopping) self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): @@ -905,7 +914,7 @@ class HomeAssistant: self._async_log_running_tasks("stop integrations") # Stage 3 - Final write - self.state = CoreState.final_write + self.set_state(CoreState.final_write) self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): @@ -918,7 +927,7 @@ class HomeAssistant: self._async_log_running_tasks("final write") # Stage 4 - Close - self.state = CoreState.not_running + self.set_state(CoreState.not_running) self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) # Make a copy of running_tasks since a task can finish @@ -969,7 +978,7 @@ class HomeAssistant: ) self._async_log_running_tasks("close") - self.state = CoreState.stopped + self.set_state(CoreState.stopped) if self._stopped is not None: self._stopped.set() @@ -996,8 +1005,6 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict") - def __init__( self, user_id: str | None = None, @@ -1009,23 +1016,37 @@ class Context: self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None - self._as_dict: ReadOnlyDict[str, str | None] | None = None def __eq__(self, other: Any) -> bool: """Compare contexts.""" return bool(self.__class__ == other.__class__ and self.id == other.id) + @cached_property + def _as_dict(self) -> dict[str, str | None]: + """Return a dictionary representation of the context. + + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + return { + "id": self.id, + "parent_id": self.parent_id, + "user_id": self.user_id, + } + def as_dict(self) -> ReadOnlyDict[str, str | None]: - """Return a dictionary representation of the context.""" - if not self._as_dict: - self._as_dict = ReadOnlyDict( - { - "id": self.id, - "parent_id": self.parent_id, - "user_id": self.user_id, - } - ) - return self._as_dict + """Return a ReadOnlyDict representation of the context.""" + return self._as_read_only_dict + + @cached_property + def _as_read_only_dict(self) -> ReadOnlyDict[str, str | None]: + """Return a ReadOnlyDict representation of the context.""" + return ReadOnlyDict(self._as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return a JSON fragment of the context.""" + return json_fragment(json_bytes(self._as_dict)) class EventOrigin(enum.Enum): @@ -1042,8 +1063,6 @@ class EventOrigin(enum.Enum): class Event: """Representation of an event within the bus.""" - __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict") - def __init__( self, event_type: str, @@ -1062,26 +1081,59 @@ class Event: id=ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) self.context = context - self._as_dict: ReadOnlyDict[str, Any] | None = None if not context.origin_event: context.origin_event = self - def as_dict(self) -> ReadOnlyDict[str, Any]: + @cached_property + def time_fired_timestamp(self) -> float: + """Return time fired as a timestamp.""" + return self.time_fired.timestamp() + + @cached_property + def _as_dict(self) -> dict[str, Any]: """Create a dict representation of this Event. + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + return { + "event_type": self.event_type, + "data": self.data, + "origin": self.origin.value, + "time_fired": self.time_fired.isoformat(), + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + "context": self.context._as_dict, # pylint: disable=protected-access + } + + def as_dict(self) -> ReadOnlyDict[str, Any]: + """Create a ReadOnlyDict representation of this Event. + Async friendly. """ - if not self._as_dict: - self._as_dict = ReadOnlyDict( - { - "event_type": self.event_type, - "data": ReadOnlyDict(self.data), - "origin": self.origin.value, - "time_fired": self.time_fired.isoformat(), - "context": self.context.as_dict(), - } - ) - return self._as_dict + return self._as_read_only_dict + + @cached_property + def _as_read_only_dict(self) -> ReadOnlyDict[str, Any]: + """Create a ReadOnlyDict representation of this Event.""" + as_dict = self._as_dict + data = as_dict["data"] + context = as_dict["context"] + # json_fragment will serialize data from a ReadOnlyDict + # or a normal dict so its ok to have either. We only + # mutate the cache if someone asks for the as_dict version + # to avoid storing multiple copies of the data in memory. + if type(data) is not ReadOnlyDict: + as_dict["data"] = ReadOnlyDict(data) + if type(context) is not ReadOnlyDict: + as_dict["context"] = ReadOnlyDict(context) + return ReadOnlyDict(as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return an event as a JSON fragment.""" + return json_fragment(json_bytes(self._as_dict)) def __repr__(self) -> str: """Return the representation.""" @@ -1101,6 +1153,23 @@ _FilterableJobType = tuple[ ] +@dataclass(slots=True) +class _OneTimeListener: + hass: HomeAssistant + listener: Callable[[Event], Coroutine[Any, Any, None] | None] + remove: CALLBACK_TYPE | None = None + + @callback + def async_call(self, event: Event) -> None: + """Remove listener from event bus and then fire listener.""" + if not self.remove: + # If the listener was already removed, we don't need to do anything + return + self.remove() + self.remove = None + self.hass.async_run_job(self.listener, event) + + class EventBus: """Allow the firing of and listening for events.""" @@ -1292,39 +1361,21 @@ class EventBus: This method must be run in the event loop. """ - filterable_job: _FilterableJobType | None = None - - @callback - def _onetime_listener(event: Event) -> None: - """Remove listener from event bus and then fire listener.""" - nonlocal filterable_job - if hasattr(_onetime_listener, "run"): - return - # Set variable so that we will never run twice. - # Because the event bus loop might have async_fire queued multiple - # times, its possible this listener may already be lined up - # multiple times as well. - # This will make sure the second time it does nothing. - setattr(_onetime_listener, "run", True) - assert filterable_job is not None - self._async_remove_listener(event_type, filterable_job) - self._hass.async_run_job(listener, event) - - functools.update_wrapper( - _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] - ) - - filterable_job = ( - HassJob( - _onetime_listener, - f"onetime listen {event_type} {listener}", - job_type=HassJobType.Callback, + one_time_listener = _OneTimeListener(self._hass, listener) + remove = self._async_listen_filterable_job( + event_type, + ( + HassJob( + one_time_listener.async_call, + f"onetime listen {event_type} {listener}", + job_type=HassJobType.Callback, + ), + None, + False, ), - None, - False, ) - - return self._async_listen_filterable_job(event_type, filterable_job) + one_time_listener.remove = remove + return remove @callback def _async_remove_listener( @@ -1385,51 +1436,96 @@ class State: 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() self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) - self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - @property + @cached_property def name(self) -> str: """Name of this state.""" return self.attributes.get(ATTR_FRIENDLY_NAME) or self.object_id.replace( "_", " " ) - def as_dict(self) -> ReadOnlyDict[str, Collection[Any]]: + @cached_property + def last_updated_timestamp(self) -> float: + """Timestamp of last update.""" + return self.last_updated.timestamp() + + @cached_property + def last_changed_timestamp(self) -> float: + """Timestamp of last change.""" + return self.last_changed.timestamp() + + @cached_property + def _as_dict(self) -> dict[str, Any]: """Return a dict representation of the State. + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + last_changed_isoformat = self.last_changed.isoformat() + if self.last_changed == self.last_updated: + last_updated_isoformat = last_changed_isoformat + else: + last_updated_isoformat = self.last_updated.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + "context": self.context._as_dict, # pylint: disable=protected-access + } + + def as_dict( + self, + ) -> ReadOnlyDict[str, datetime.datetime | Collection[Any]]: + """Return a ReadOnlyDict representation of the State. + Async friendly. - To be used for JSON serialization. + Can be used for JSON serialization. Ensures: state == State.from_dict(state.as_dict()) """ - if not self._as_dict: - last_changed_isoformat = self.last_changed.isoformat() - if self.last_changed == self.last_updated: - last_updated_isoformat = last_changed_isoformat - else: - last_updated_isoformat = self.last_updated.isoformat() - self._as_dict = ReadOnlyDict( - { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - "context": self.context.as_dict(), - } - ) - return self._as_dict + return self._as_read_only_dict @cached_property - def as_dict_json(self) -> str: + def _as_read_only_dict( + self, + ) -> ReadOnlyDict[str, datetime.datetime | Collection[Any]]: + """Return a ReadOnlyDict representation of the State.""" + as_dict = self._as_dict + context = as_dict["context"] + # json_fragment will serialize data from a ReadOnlyDict + # or a normal dict so its ok to have either. We only + # mutate the cache if someone asks for the as_dict version + # to avoid storing multiple copies of the data in memory. + if type(context) is not ReadOnlyDict: + as_dict["context"] = ReadOnlyDict(context) + return ReadOnlyDict(as_dict) + + @cached_property + def as_dict_json(self) -> bytes: """Return a JSON string of the State.""" - return json_dumps(self.as_dict()) + return json_bytes(self._as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return a JSON fragment of the State.""" + return json_fragment(self.as_dict_json) @cached_property def as_compressed_state(self) -> dict[str, Any]: @@ -1443,28 +1539,31 @@ class State: if state_context.parent_id is None and state_context.user_id is None: context: dict[str, Any] | str = state_context.id else: - context = state_context.as_dict() + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + context = state_context._as_dict # pylint: disable=protected-access compressed_state = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: dt_util.utc_to_timestamp(self.last_changed), + COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, } if self.last_changed != self.last_updated: - compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( - self.last_updated - ) + compressed_state[ + COMPRESSED_STATE_LAST_UPDATED + ] = self.last_updated_timestamp return compressed_state @cached_property - def as_compressed_state_json(self) -> str: + def as_compressed_state_json(self) -> bytes: """Build a compressed JSON key value pair of a state for adds. The JSON string is a key value pair of the entity_id and the compressed state. It is used for sending multiple states in a single message. """ - return json_dumps({self.entity_id: self.as_compressed_state})[1:-1] + return json_bytes({self.entity_id: self.as_compressed_state})[1:-1] @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: @@ -1804,6 +1903,11 @@ class StateMachine: 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, @@ -1915,10 +2019,36 @@ class ServiceRegistry: def async_services(self) -> dict[str, dict[str, Service]]: """Return dictionary with per domain a list of available services. + This method makes a copy of the registry. This function is expensive, + and should only be used if has_service is not sufficient. + This method must be run in the event loop. """ return {domain: service.copy() for domain, service in self._services.items()} + @callback + def async_services_for_domain(self, domain: str) -> dict[str, Service]: + """Return dictionary with per domain a list of available services. + + This method makes a copy of the registry for the domain. + + This method must be run in the event loop. + """ + return self._services.get(domain, {}).copy() + + @callback + def async_services_internal(self) -> dict[str, dict[str, Service]]: + """Return dictionary with per domain a list of available services. + + This method DOES NOT make a copy of the services like async_services does. + It is only expected to be called from the Home Assistant internals + as a performance optimization when the caller is not going to modify the + returned data. + + This method must be run in the event loop. + """ + return self._services + def has_service(self, domain: str, service: str) -> bool: """Test if specified service exists. @@ -2098,11 +2228,11 @@ class ServiceRegistry: raise ValueError( "Invalid argument return_response=True when blocking=False" ) - if handler.supports_response == SupportsResponse.NONE: + if handler.supports_response is SupportsResponse.NONE: raise ValueError( "Invalid argument return_response=True when handler does not support responses" ) - elif handler.supports_response == SupportsResponse.ONLY: + elif handler.supports_response is SupportsResponse.ONLY: raise ValueError( "Service call requires responses but caller did not ask for responses" ) @@ -2180,11 +2310,11 @@ class ServiceRegistry: """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) @@ -2311,6 +2441,7 @@ class Config: Async friendly. """ + allowlist_external_dirs = list(self.allowlist_external_dirs) return { "latitude": self.latitude, "longitude": self.longitude, @@ -2318,12 +2449,12 @@ class Config: "unit_system": self.units.as_dict(), "location_name": self.location_name, "time_zone": self.time_zone, - "components": self.components, + "components": list(self.components), "config_dir": self.config_dir, # legacy, backwards compat - "whitelist_external_dirs": self.allowlist_external_dirs, - "allowlist_external_dirs": self.allowlist_external_dirs, - "allowlist_external_urls": self.allowlist_external_urls, + "whitelist_external_dirs": allowlist_external_dirs, + "allowlist_external_dirs": allowlist_external_dirs, + "allowlist_external_urls": list(self.allowlist_external_urls), "version": __version__, "config_source": self.config_source, "recovery_mode": self.recovery_mode, diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 63ba565582a..d08e76edbd2 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -2,7 +2,9 @@ from __future__ import annotations import abc +import asyncio from collections.abc import Callable, Iterable, Mapping +from contextlib import suppress import copy from dataclasses import dataclass from enum import StrEnum @@ -22,6 +24,7 @@ from .helpers.deprecation import ( dir_with_deprecated_constants, ) from .helpers.frame import report +from .loader import async_suggest_report_issue from .util import uuid as uuid_util _LOGGER = logging.getLogger(__name__) @@ -72,6 +75,13 @@ FLOW_NOT_COMPLETE_STEPS = { FlowResultType.MENU, } +STEP_ID_OPTIONAL_STEPS = { + FlowResultType.EXTERNAL_STEP, + FlowResultType.FORM, + FlowResultType.MENU, + FlowResultType.SHOW_PROGRESS, +} + @dataclass(slots=True) class BaseServiceInfo: @@ -94,6 +104,23 @@ class UnknownStep(FlowError): """Unknown step specified.""" +# ignore misc is required as vol.Invalid is not typed +# mypy error: Class cannot subclass "Invalid" (has type "Any") +class InvalidData(vol.Invalid): # type: ignore[misc] + """Invalid data provided.""" + + def __init__( + self, + message: str, + path: list[str | vol.Marker] | None, + error_message: str | None, + schema_errors: dict[str, Any], + **kwargs: Any, + ) -> None: + super().__init__(message, path, error_message, **kwargs) + self.schema_errors = schema_errors + + class AbortFlow(FlowError): """Exception to indicate a flow needs to be aborted.""" @@ -124,6 +151,7 @@ class FlowResult(TypedDict, total=False): options: Mapping[str, Any] preview: str | None progress_action: str + progress_task: asyncio.Task[Any] | None reason: str required: bool result: Any @@ -154,6 +182,29 @@ def _async_flow_handler_to_flow_result( return results +def _map_error_to_schema_errors( + schema_errors: dict[str, Any], + error: vol.Invalid, + data_schema: vol.Schema, +) -> None: + """Map an error to the correct position in the schema_errors. + + Raises ValueError if the error path could not be found in the schema. + Limitation: Nested schemas are not supported and a ValueError will be raised. + """ + schema = data_schema.schema + error_path = error.path + if not error_path or (path_part := error_path[0]) not in schema: + raise ValueError("Could not find path in schema") + + if len(error_path) > 1: + raise ValueError("Nested schemas are not supported") + + # path_part can also be vol.Marker, but we need a string key + path_part_str = str(path_part) + schema_errors[path_part_str] = error.error_message + + class FlowManager(abc.ABC): """Manage all the flows that are in progress.""" @@ -201,11 +252,13 @@ class FlowManager(abc.ABC): If match_context is passed, only return flows with a context that is a superset of match_context. """ - return any( - flow - for flow in self._async_progress_by_handler(handler, match_context) - if flow.init_data == data - ) + if not (flows := self._handler_progress_index.get(handler)): + return False + match_items = match_context.items() + for progress in flows: + if match_items <= progress.context.items() and progress.init_data == data: + return True + return False @callback def async_get(self, flow_id: str) -> FlowResult: @@ -265,11 +318,11 @@ class FlowManager(abc.ABC): is a superset of match_context. """ if not match_context: - return list(self._handler_progress_index.get(handler, [])) + return list(self._handler_progress_index.get(handler, ())) match_context_items = match_context.items() return [ progress - for progress in self._handler_progress_index.get(handler, set()) + for progress in self._handler_progress_index.get(handler, ()) if match_context_items <= progress.context.items() ] @@ -298,6 +351,18 @@ class FlowManager(abc.ABC): async def async_configure( self, flow_id: str, user_input: dict | None = None + ) -> FlowResult: + """Continue a data entry flow.""" + result: FlowResult | None = None + while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE: + result = await self._async_configure(flow_id, user_input) + flow = self._progress.get(flow_id) + if flow and flow.deprecated_show_progress: + break + return result + + async def _async_configure( + self, flow_id: str, user_input: dict | None = None ) -> FlowResult: """Continue a data entry flow.""" if (flow := self._progress.get(flow_id)) is None: @@ -309,7 +374,26 @@ class FlowManager(abc.ABC): if ( data_schema := cur_step.get("data_schema") ) is not None and user_input is not None: - user_input = data_schema(user_input) + try: + user_input = data_schema(user_input) + except vol.Invalid as ex: + raised_errors = [ex] + if isinstance(ex, vol.MultipleInvalid): + raised_errors = ex.errors + + schema_errors: dict[str, Any] = {} + for error in raised_errors: + try: + _map_error_to_schema_errors(schema_errors, error, data_schema) + except ValueError: + # If we get here, the path in the exception does not exist in the schema. + schema_errors.setdefault("base", []).append(str(error)) + raise InvalidData( + "Schema validation failed", + path=ex.path, + error_message=ex.error_message, + schema_errors=schema_errors, + ) from ex # Handle a menu navigation choice if cur_step["type"] == FlowResultType.MENU and user_input: @@ -400,6 +484,7 @@ class FlowManager(abc.ABC): if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow self._async_remove_flow_from_index(flow) + flow.async_cancel_progress_task() try: flow.async_remove() except Exception as err: # pylint: disable=broad-except @@ -433,6 +518,29 @@ class FlowManager(abc.ABC): error_if_core=False, ) + if ( + result["type"] == FlowResultType.SHOW_PROGRESS + and (progress_task := result.pop("progress_task", None)) + and progress_task != flow.async_get_progress_task() + ): + # The flow's progress task was changed, register a callback on it + async def call_configure() -> None: + with suppress(UnknownFlow): + await self._async_configure(flow.flow_id) + + def schedule_configure(_: asyncio.Task) -> None: + self.hass.async_create_task(call_configure()) + + progress_task.add_done_callback(schedule_configure) + flow.async_set_progress_task(progress_task) + + elif result["type"] != FlowResultType.SHOW_PROGRESS: + flow.async_cancel_progress_task() + + if result["type"] in STEP_ID_OPTIONAL_STEPS: + if "step_id" not in result: + result["step_id"] = step_id + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result @@ -492,6 +600,10 @@ class FlowHandler: VERSION = 1 MINOR_VERSION = 1 + __progress_task: asyncio.Task[Any] | None = None + __no_progress_task_reported = False + deprecated_show_progress = False + @property def source(self) -> str | None: """Source that initialized the flow.""" @@ -538,25 +650,30 @@ class FlowHandler: def async_show_form( self, *, - step_id: str, + step_id: str | None = None, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, preview: str | None = None, ) -> FlowResult: - """Return the definition of a form to gather user input.""" - return FlowResult( + """Return the definition of a form to gather user input. + + The step_id parameter is deprecated and will be removed in a future release. + """ + flow_result = FlowResult( type=FlowResultType.FORM, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, data_schema=data_schema, errors=errors, description_placeholders=description_placeholders, last_step=last_step, # Display next or submit button in frontend preview=preview, # Display preview component in frontend ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_create_entry( @@ -599,19 +716,24 @@ class FlowHandler: def async_external_step( self, *, - step_id: str, + step_id: str | None = None, url: str, description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: - """Return the definition of an external step for the user to take.""" - return FlowResult( + """Return the definition of an external step for the user to take. + + The step_id parameter is deprecated and will be removed in a future release. + """ + flow_result = FlowResult( type=FlowResultType.EXTERNAL_STEP, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, url=url, description_placeholders=description_placeholders, ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_external_step_done(self, *, next_step_id: str) -> FlowResult: @@ -627,19 +749,44 @@ class FlowHandler: def async_show_progress( self, *, - step_id: str, + step_id: str | None = None, progress_action: str, description_placeholders: Mapping[str, str] | None = None, + progress_task: asyncio.Task[Any] | None = None, ) -> FlowResult: - """Show a progress message to the user, without user input allowed.""" - return FlowResult( + """Show a progress message to the user, without user input allowed. + + The step_id parameter is deprecated and will be removed in a future release. + """ + if progress_task is None and not self.__no_progress_task_reported: + self.__no_progress_task_reported = True + cls = self.__class__ + report_issue = async_suggest_report_issue(self.hass, module=cls.__module__) + _LOGGER.warning( + ( + "%s::%s calls async_show_progress without passing a progress task, " + "this is not valid and will break in Home Assistant Core 2024.8. " + "Please %s" + ), + cls.__module__, + cls.__name__, + report_issue, + ) + + if progress_task is None: + self.deprecated_show_progress = True + + flow_result = FlowResult( type=FlowResultType.SHOW_PROGRESS, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, progress_action=progress_action, description_placeholders=description_placeholders, + progress_task=progress_task, ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: @@ -655,23 +802,26 @@ class FlowHandler: def async_show_menu( self, *, - step_id: str, + step_id: str | None = None, menu_options: list[str] | dict[str, str], description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a navigation menu to the user. Options dict maps step_id => i18n label + The step_id parameter is deprecated and will be removed in a future release. """ - return FlowResult( + flow_result = FlowResult( type=FlowResultType.MENU, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, data_schema=vol.Schema({"next_step_id": vol.In(menu_options)}), menu_options=menu_options, description_placeholders=description_placeholders, ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_remove(self) -> None: @@ -681,6 +831,26 @@ class FlowHandler: async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" + @callback + def async_cancel_progress_task(self) -> None: + """Cancel in progress task.""" + if self.__progress_task and not self.__progress_task.done(): + self.__progress_task.cancel() + self.__progress_task = None + + @callback + def async_get_progress_task(self) -> asyncio.Task[Any] | None: + """Get in progress task.""" + return self.__progress_task + + @callback + def async_set_progress_task( + self, + progress_task: asyncio.Task[Any], + ) -> None: + """Set in progress task.""" + self.__progress_task = progress_task + @callback def _create_abort_data( diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 060080517bf..586aa64ce18 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lametric", "lyric", + "myuplink", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 13700a4521c..7d32dbfe963 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -120,6 +120,12 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "govee_ble", "local_name": "B5178*", }, + { + "connectable": False, + "domain": "govee_ble", + "manufacturer_id": 1, + "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "govee_ble", @@ -343,6 +349,15 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 12, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "domain": "oralb", "manufacturer_id": 220, @@ -424,6 +439,12 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "sensorpush", "local_name": "SensorPush*", }, + { + "connectable": False, + "domain": "sensorpush", + "local_name": "s", + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9", + }, { "domain": "snooz", "local_name": "Snooz*", @@ -522,6 +543,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "thermopro", "local_name": "TP39*", }, + { + "connectable": False, + "domain": "thermopro", + "local_name": "TP96*", + }, { "domain": "tilt_ble", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cba1a88d25b..aa3efde99bc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -33,6 +33,7 @@ FLOWS = { "airthings", "airthings_ble", "airtouch4", + "airtouch5", "airvisual", "airvisual_pro", "airzone", @@ -42,6 +43,7 @@ FLOWS = { "amberelectric", "ambiclimate", "ambient_station", + "analytics_insights", "android_ip_webcam", "androidtv", "androidtv_remote", @@ -65,6 +67,7 @@ FLOWS = { "azure_event_hub", "baf", "balboa", + "bang_olufsen", "blebox", "blink", "blue_current", @@ -74,6 +77,7 @@ FLOWS = { "bond", "bosch_shc", "braviatv", + "bring", "broadlink", "brother", "brottsplatskartan", @@ -126,6 +130,7 @@ FLOWS = { "ecobee", "ecoforest", "econet", + "ecovacs", "ecowitt", "edl21", "efergy", @@ -134,12 +139,14 @@ FLOWS = { "elgato", "elkm1", "elmax", + "elvia", "emonitor", "emulated_roku", "energyzero", "enocean", "enphase_envoy", "environment_canada", + "epion", "epson", "escea", "esphome", @@ -195,6 +202,8 @@ FLOWS = { "google_translate", "google_travel_time", "govee_ble", + "govee_light_local", + "gpsd", "gpslogger", "gree", "growatt_server", @@ -205,10 +214,10 @@ FLOWS = { "here_travel_time", "hisense_aehw4a1", "hive", + "hko", "hlk_sw16", "holiday", "home_connect", - "home_plus_control", "homeassistant_sky_connect", "homekit", "homekit_controller", @@ -219,6 +228,7 @@ FLOWS = { "hue", "huisbaasje", "hunterdouglas_powerview", + "huum", "hvv_departures", "hydrawise", "hyperion", @@ -258,16 +268,17 @@ FLOWS = { "kraken", "kulersky", "lacrosse_view", + "lamarzocco", "lametric", "landisgyr_heat_meter", "lastfm", "launch_library", "laundrify", "ld2410_ble", + "leaone", "led_ble", "lg_soundbar", "lidarr", - "life360", "lifx", "linear_garage_door", "litejet", @@ -281,6 +292,8 @@ FLOWS = { "lookin", "loqed", "luftdaten", + "lupusec", + "lutron", "lutron_caseta", "lyric", "mailgun", @@ -314,6 +327,7 @@ FLOWS = { "mutesync", "mysensors", "mystrom", + "myuplink", "nam", "nanoleaf", "neato", @@ -381,6 +395,7 @@ FLOWS = { "profiler", "progettihwsw", "prosegur", + "proximity", "prusalink", "ps4", "pure_energie", @@ -393,12 +408,14 @@ FLOWS = { "qingping", "qnap", "qnap_qsw", + "rabbitair", "rachio", "radarr", "radio_browser", "radiotherm", "rainbird", "rainforest_eagle", + "rainforest_raven", "rainmachine", "rapt_ble", "rdw", @@ -415,6 +432,7 @@ FLOWS = { "rituals_perfume_genie", "roborock", "roku", + "romy", "roomba", "roon", "rpi_power", @@ -500,8 +518,11 @@ FLOWS = { "tankerkoenig", "tasmota", "tautulli", + "technove", + "tedee", "tellduslive", "tesla_wall_connector", + "teslemetry", "tessie", "thermobeacon", "thermopro", @@ -509,6 +530,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "time_date", "todoist", "tolo", "tomorrowio", @@ -517,6 +539,7 @@ FLOWS = { "tplink", "tplink_omada", "traccar", + "traccar_server", "tractive", "tradfri", "trafikverket_camera", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 33d069c5663..a6722282e35 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -603,6 +603,11 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "tplink", "registered_devices": True, }, + { + "domain": "tplink", + "hostname": "e[sp]*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "e[sp]*", @@ -633,6 +638,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "hs*", "macaddress": "9C5322*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "5091E3*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -778,6 +788,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "54AF97*", }, + { + "domain": "tplink", + "hostname": "l[59]*", + "macaddress": "54AF97*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -798,6 +813,116 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "1C61B4*", }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "5CE931*", + }, + { + "domain": "tplink", + "hostname": "l[59]*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "5C628B*", + }, + { + "domain": "tplink", + "hostname": "tp*", + "macaddress": "5C628B*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "482254*", + }, + { + "domain": "tplink", + "hostname": "s5*", + "macaddress": "482254*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "30DE4B*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "tp*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "s5*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "A842A1*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "3460F9*", + }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "704F57*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "74DA88*", + }, + { + "domain": "tplink", + "hostname": "p3*", + "macaddress": "788CB5*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "D80D17*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "D84732*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "F0A731*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "F0A731*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 45bcc1788cd..ae839180729 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -128,6 +128,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "airtouch5": { + "name": "AirTouch 5", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "airvisual": { "name": "AirVisual", "integrations": { @@ -255,6 +261,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "analytics_insights": { + "name": "Home Assistant Analytics Insights", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "android_ip_webcam": { "name": "Android IP Webcam", "integration_type": "hub", @@ -569,6 +581,12 @@ "config_flow": true, "iot_class": "local_push" }, + "bang_olufsen": { + "name": "Bang & Olufsen", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "bayesian": { "name": "Bayesian", "integration_type": "hub", @@ -714,6 +732,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "bring": { + "name": "Bring!", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "broadlink": { "name": "Broadlink", "integration_type": "hub", @@ -900,6 +924,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "coautilities": { + "name": "City of Austin Utilities", + "integration_type": "virtual", + "supported_by": "opower" + }, "coinbase": { "name": "Coinbase", "integration_type": "hub", @@ -1381,7 +1410,7 @@ "ecovacs": { "name": "Ecovacs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push" }, "ecowitt": { @@ -1473,6 +1502,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "elvia": { + "name": "Elvia", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "emby": { "name": "Emby", "integration_type": "hub", @@ -1577,6 +1612,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "epion": { + "name": "Epion", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "epson": { "name": "Epson", "integrations": { @@ -1680,12 +1721,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "facebox": { - "name": "Facebox", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" - }, "fail2ban": { "name": "Fail2Ban", "integration_type": "hub", @@ -2266,16 +2301,27 @@ } } }, - "govee_ble": { - "name": "Govee Bluetooth", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "govee": { + "name": "Govee", + "integrations": { + "govee_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee Bluetooth" + }, + "govee_light_local": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee lights local" + } + } }, "gpsd": { "name": "GPSD", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "gpslogger": { @@ -2446,6 +2492,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hko": { + "name": "Hong Kong Observatory", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hlk_sw16": { "name": "Hi-Link HLK-SW16", "integration_type": "hub", @@ -2453,7 +2505,6 @@ "iot_class": "local_push" }, "holiday": { - "name": "Holiday", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -2466,9 +2517,8 @@ }, "home_plus_control": { "name": "Legrand Home+ Control", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" + "integration_type": "virtual", + "supported_by": "netatmo" }, "homematic": { "name": "Homematic", @@ -2563,6 +2613,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "huum": { + "name": "Huum", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hvv_departures": { "name": "HVV Departures", "integration_type": "hub", @@ -2994,6 +3050,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "lamarzocco": { + "name": "La Marzocco", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "lametric": { "name": "LaMetric", "integration_type": "device", @@ -3042,6 +3104,12 @@ "config_flow": true, "iot_class": "local_push" }, + "leaone": { + "name": "LeaOne", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "led_ble": { "name": "LED BLE", "integration_type": "hub", @@ -3088,12 +3156,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "life360": { - "name": "Life360", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "lifx": { "name": "LIFX", "integration_type": "hub", @@ -3267,7 +3329,7 @@ "lupusec": { "name": "Lupus Electronics LUPUSEC", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "lutron": { @@ -3275,7 +3337,7 @@ "integrations": { "lutron": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Lutron" }, @@ -3726,6 +3788,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "myuplink": { + "name": "myUplink", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nad": { "name": "NAD", "integration_type": "hub", @@ -4500,7 +4568,7 @@ }, "proximity": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "calculated" }, "proxmoxve": { @@ -4659,6 +4727,12 @@ "config_flow": false, "iot_class": "local_push" }, + "rabbitair": { + "name": "Rabbit Air", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rachio": { "name": "Rachio", "integration_type": "hub", @@ -4689,11 +4763,22 @@ "config_flow": true, "iot_class": "local_polling" }, - "rainforest_eagle": { - "name": "Rainforest Eagle", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "rainforest": { + "name": "Rainforest Automation", + "integrations": { + "rainforest_eagle": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest Eagle" + }, + "rainforest_raven": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest RAVEn" + } + } }, "rainmachine": { "name": "RainMachine", @@ -4895,6 +4980,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "romy": { + "name": "ROMY Vacuum Cleaner", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "roomba": { "name": "iRobot Roomba and Braava", "integration_type": "hub", @@ -5798,12 +5889,24 @@ "config_flow": false, "iot_class": "local_polling" }, + "technove": { + "name": "TechnoVE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ted5000": { "name": "The Energy Detective TED5000", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" }, + "tedee": { + "name": "Tedee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "telegram": { "name": "Telegram", "integrations": { @@ -5873,6 +5976,12 @@ } } }, + "teslemetry": { + "name": "Teslemetry", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tessie": { "name": "Tessie", "integration_type": "hub", @@ -5969,9 +6078,8 @@ "iot_class": "local_push" }, "time_date": { - "name": "Time & Date", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_push" }, "tmb": { @@ -6035,7 +6143,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "TP-Link Kasa Smart" + "name": "TP-Link Smart Home" }, "tplink_omada": { "integration_type": "hub", @@ -6048,6 +6156,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "TP-Link LTE" + }, + "tplink_tapo": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "tplink", + "name": "Tapo" } }, "iot_standards": [ @@ -6056,9 +6170,20 @@ }, "traccar": { "name": "Traccar", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "traccar": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "Traccar Client" + }, + "traccar_server": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Traccar Server" + } + } }, "tractive": { "name": "Tractive", @@ -6965,6 +7090,7 @@ "google_travel_time", "group", "growatt_server", + "holiday", "homekit_controller", "input_boolean", "input_button", @@ -6993,6 +7119,7 @@ "switch_as_x", "tag", "threshold", + "time_date", "tod", "uptime", "utility_meter", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2fdd032c2dd..ce40f481d96 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -19,6 +19,20 @@ USB = [ "pid": "1340", "vid": "0572", }, + { + "description": "*raven*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "8A28", + "vid": "0403", + }, + { + "description": "*emu-2*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "0003", + "vid": "04B4", + }, { "domain": "velbus", "pid": "0B1B", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fea1d4ec889..a66efa6dded 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -248,6 +248,11 @@ ZEROCONF = { "domain": "volumio", }, ], + "_aicu-http._tcp.local.": [ + { + "domain": "romy", + }, + ], "_airplay._tcp.local.": [ { "domain": "apple_tv", @@ -344,6 +349,11 @@ ZEROCONF = { }, }, ], + "_bangolufsen._tcp.local.": [ + { + "domain": "bang_olufsen", + }, + ], "_bbxsrv._tcp.local.": [ { "domain": "blebox", @@ -621,6 +631,11 @@ ZEROCONF = { "name": "brother*", }, ], + "_rabbitair._udp.local.": [ + { + "domain": "rabbitair", + }, + ], "_raop._tcp.local.": [ { "domain": "apple_tv", @@ -700,6 +715,11 @@ ZEROCONF = { "domain": "system_bridge", }, ], + "_technove-stations._tcp.local.": [ + { + "domain": "technove", + }, + ], "_touch-able._tcp.local.": [ { "domain": "apple_tv", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 499e548ce90..e55f71beb88 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -1,11 +1,10 @@ """Provide a way to connect devices to one physical location.""" from __future__ import annotations -from collections import OrderedDict -from collections.abc import Container, Iterable, MutableMapping -from typing import Any, cast - -import attr +from collections import UserDict +from collections.abc import Iterable, ValuesView +import dataclasses +from typing import Any, Literal, TypedDict, cast from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -18,30 +17,73 @@ DATA_REGISTRY = "area_registry" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 -@attr.s(slots=True, frozen=True) +class EventAreaRegistryUpdatedData(TypedDict): + """EventAreaRegistryUpdated data.""" + + action: Literal["create", "remove", "update"] + area_id: str + + +@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) class AreaEntry: """Area Registry Entry.""" - name: str = attr.ib() - normalized_name: str = attr.ib() - aliases: set[str] = attr.ib( - converter=attr.converters.default_if_none(factory=set) # type: ignore[misc] - ) - id: str | None = attr.ib(default=None) - picture: str | None = attr.ib(default=None) + aliases: set[str] + icon: str | None + id: str + name: str + normalized_name: str + picture: str | None - def generate_id(self, existing_ids: Container[str]) -> None: - """Initialize ID.""" - suggestion = suggestion_base = slugify(self.name) - tries = 1 - while suggestion in existing_ids: - tries += 1 - suggestion = f"{suggestion_base}_{tries}" - object.__setattr__(self, "id", suggestion) + +class AreaRegistryItems(UserDict[str, AreaEntry]): + """Container for area registry items, maps area id -> entry. + + Maintains an additional index: + - normalized name -> entry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._normalized_names: dict[str, AreaEntry] = {} + + def values(self) -> ValuesView[AreaEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: AreaEntry) -> None: + """Add an item.""" + data = self.data + normalized_name = normalize_area_name(entry.name) + + if key in data: + old_entry = data[key] + if ( + normalized_name != old_entry.normalized_name + and normalized_name in self._normalized_names + ): + raise ValueError( + f"The name {entry.name} ({normalized_name}) is already in use" + ) + del self._normalized_names[old_entry.normalized_name] + data[key] = entry + self._normalized_names[normalized_name] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + normalized_name = normalize_area_name(entry.name) + del self._normalized_names[normalized_name] + super().__delitem__(key) + + def get_area_by_name(self, name: str) -> AreaEntry | None: + """Get area by name.""" + return self._normalized_names.get(normalize_area_name(name)) class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): @@ -66,6 +108,11 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): for area in old_data["areas"]: area["aliases"] = [] + if old_minor_version < 4: + # Version 1.4 adds icon + for area in old_data["areas"]: + area["icon"] = None + if old_major_version > 1: raise NotImplementedError return old_data @@ -74,10 +121,12 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): class AreaRegistry: """Class to hold a registry of areas.""" + areas: AreaRegistryItems + _area_data: dict[str, AreaEntry] + def __init__(self, hass: HomeAssistant) -> None: """Initialize the area registry.""" self.hass = hass - self.areas: MutableMapping[str, AreaEntry] = {} self._store = AreaRegistryStore( hass, STORAGE_VERSION_MAJOR, @@ -85,20 +134,20 @@ class AreaRegistry: atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, ) - self._normalized_name_area_idx: dict[str, str] = {} @callback def async_get_area(self, area_id: str) -> AreaEntry | None: - """Get area by id.""" - return self.areas.get(area_id) + """Get area by id. + + We retrieve the DeviceEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._area_data.get(area_id) @callback def async_get_area_by_name(self, name: str) -> AreaEntry | None: """Get area by name.""" - normalized_name = normalize_area_name(name) - if normalized_name not in self._normalized_name_area_idx: - return None - return self.areas[self._normalized_name_area_idx[normalized_name]] + return self.areas.get_area_by_name(name) @callback def async_list_areas(self) -> Iterable[AreaEntry]: @@ -118,6 +167,7 @@ class AreaRegistry: name: str, *, aliases: set[str] | None = None, + icon: str | None = None, picture: str | None = None, ) -> AreaEntry: """Create a new area.""" @@ -126,13 +176,17 @@ class AreaRegistry: if self.async_get_area_by_name(name): raise ValueError(f"The name {name} ({normalized_name}) is already in use") + area_id = self._generate_area_id(name) area = AreaEntry( - aliases=aliases, name=name, normalized_name=normalized_name, picture=picture + aliases=aliases or set(), + icon=icon, + id=area_id, + name=name, + normalized_name=normalized_name, + picture=picture, ) - area.generate_id(self.areas) assert area.id is not None self.areas[area.id] = area - self._normalized_name_area_idx[normalized_name] = area.id self.async_schedule_save() self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "create", "area_id": area.id} @@ -142,14 +196,12 @@ class AreaRegistry: @callback def async_delete(self, area_id: str) -> None: """Delete area.""" - area = self.areas[area_id] device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) entity_registry.async_clear_area_id(area_id) del self.areas[area_id] - del self._normalized_name_area_idx[area.normalized_name] self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "remove", "area_id": area_id} @@ -163,12 +215,17 @@ class AreaRegistry: area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( - area_id, aliases=aliases, name=name, picture=picture + area_id, + aliases=aliases, + icon=icon, + name=name, + picture=picture, ) self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "update", "area_id": area_id} @@ -181,6 +238,7 @@ class AreaRegistry: area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: @@ -191,34 +249,20 @@ class AreaRegistry: for attr_name, value in ( ("aliases", aliases), + ("icon", icon), ("picture", picture), ): if value is not UNDEFINED and value != getattr(old, attr_name): new_values[attr_name] = value - normalized_name = None - if name is not UNDEFINED and name != old.name: - normalized_name = normalize_area_name(name) - - if normalized_name != old.normalized_name and self.async_get_area_by_name( - name - ): - raise ValueError( - f"The name {name} ({normalized_name}) is already in use" - ) - new_values["name"] = name - new_values["normalized_name"] = normalized_name + new_values["normalized_name"] = normalize_area_name(name) if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] - if normalized_name is not None: - self._normalized_name_area_idx[ - normalized_name - ] = self._normalized_name_area_idx.pop(old.normalized_name) + new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] self.async_schedule_save() return new @@ -227,7 +271,7 @@ class AreaRegistry: """Load the area registry.""" data = await self._store.async_load() - areas: MutableMapping[str, AreaEntry] = OrderedDict() + areas = AreaRegistryItems() if data is not None: for area in data["areas"]: @@ -235,14 +279,15 @@ class AreaRegistry: normalized_name = normalize_area_name(area["name"]) areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), + icon=area["icon"], id=area["id"], name=area["name"], normalized_name=normalized_name, picture=area["picture"], ) - self._normalized_name_area_idx[normalized_name] = area["id"] self.areas = areas + self._area_data = areas.data @callback def async_schedule_save(self) -> None: @@ -257,8 +302,9 @@ class AreaRegistry: data["areas"] = [ { "aliases": list(entry.aliases), - "name": entry.name, + "icon": entry.icon, "id": entry.id, + "name": entry.name, "picture": entry.picture, } for entry in self.areas.values() @@ -266,6 +312,15 @@ class AreaRegistry: return data + def _generate_area_id(self, name: str) -> str: + """Generate area ID.""" + suggestion = suggestion_base = slugify(name) + tries = 1 + while suggestion in self.areas: + tries += 1 + suggestion = f"{suggestion_base}_{tries}" + return suggestion + @callback def async_get(hass: HomeAssistant) -> AreaRegistry: diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 5b4b803a8d4..7563d4c08b9 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -209,7 +209,10 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): error_code = error_response.get("error", "unknown") error_description = error_response.get("error_description", "unknown error") _LOGGER.error( - "Token request failed (%s): %s", error_code, error_description + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, ) resp.raise_for_status() return cast(dict, await resp.json()) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e4b62dd679d..bdf9897a4ba 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -67,6 +67,7 @@ from homeassistant.const import ( CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, + CONF_SET_CONVERSATION_RESPONSE, CONF_STATE, CONF_STOP, CONF_TARGET, @@ -1267,6 +1268,9 @@ def make_entity_service_schema( ) +SCRIPT_CONVERSATION_RESPONSE_SCHEMA = vol.Any(template, None) + + SCRIPT_VARIABLES_SCHEMA = vol.All( vol.Schema({str: template_complex}), # pylint: disable-next=unnecessary-lambda @@ -1742,6 +1746,15 @@ _SCRIPT_SET_SCHEMA = vol.Schema( } ) +_SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA = vol.Schema( + { + **SCRIPT_ACTION_BASE_SCHEMA, + vol.Required( + CONF_SET_CONVERSATION_RESPONSE + ): SCRIPT_CONVERSATION_RESPONSE_SCHEMA, + } +) + _SCRIPT_STOP_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, @@ -1780,20 +1793,21 @@ _SCRIPT_PARALLEL_SCHEMA = vol.Schema( ) -SCRIPT_ACTION_DELAY = "delay" -SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template" -SCRIPT_ACTION_CHECK_CONDITION = "condition" -SCRIPT_ACTION_FIRE_EVENT = "event" -SCRIPT_ACTION_CALL_SERVICE = "call_service" -SCRIPT_ACTION_DEVICE_AUTOMATION = "device" SCRIPT_ACTION_ACTIVATE_SCENE = "scene" -SCRIPT_ACTION_REPEAT = "repeat" +SCRIPT_ACTION_CALL_SERVICE = "call_service" +SCRIPT_ACTION_CHECK_CONDITION = "condition" SCRIPT_ACTION_CHOOSE = "choose" -SCRIPT_ACTION_WAIT_FOR_TRIGGER = "wait_for_trigger" -SCRIPT_ACTION_VARIABLES = "variables" -SCRIPT_ACTION_STOP = "stop" +SCRIPT_ACTION_DELAY = "delay" +SCRIPT_ACTION_DEVICE_AUTOMATION = "device" +SCRIPT_ACTION_FIRE_EVENT = "event" SCRIPT_ACTION_IF = "if" SCRIPT_ACTION_PARALLEL = "parallel" +SCRIPT_ACTION_REPEAT = "repeat" +SCRIPT_ACTION_SET_CONVERSATION_RESPONSE = "set_conversation_response" +SCRIPT_ACTION_STOP = "stop" +SCRIPT_ACTION_VARIABLES = "variables" +SCRIPT_ACTION_WAIT_FOR_TRIGGER = "wait_for_trigger" +SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template" def determine_script_action(action: dict[str, Any]) -> str: @@ -1840,24 +1854,28 @@ def determine_script_action(action: dict[str, Any]) -> str: if CONF_PARALLEL in action: return SCRIPT_ACTION_PARALLEL + if CONF_SET_CONVERSATION_RESPONSE in action: + return SCRIPT_ACTION_SET_CONVERSATION_RESPONSE + raise ValueError("Unable to determine action") ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { - SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA, - SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA, - SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA, - SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA, - SCRIPT_ACTION_CHECK_CONDITION: CONDITION_ACTION_SCHEMA, - SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA, - SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, + SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA, + SCRIPT_ACTION_CHECK_CONDITION: CONDITION_ACTION_SCHEMA, SCRIPT_ACTION_CHOOSE: _SCRIPT_CHOOSE_SCHEMA, - SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA, - SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, - SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, + SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA, + SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, + SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA, SCRIPT_ACTION_IF: _SCRIPT_IF_SCHEMA, SCRIPT_ACTION_PARALLEL: _SCRIPT_PARALLEL_SCHEMA, + SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, + SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA, + SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, + SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, + SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA, + SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA, } diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index aa4ef36b251..9fdd48b59f0 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -110,10 +110,8 @@ class FlowManagerResourceView(_BaseFlowManagerView): result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) - except vol.Invalid as ex: - return self.json_message( - f"User input malformed: {ex}", HTTPStatus.BAD_REQUEST - ) + except data_entry_flow.InvalidData as ex: + return self.json({"errors": ex.schema_errors}, HTTPStatus.BAD_REQUEST) result = self._prepare_result_json(result) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 18a42ce9bcf..cf76bc78aa5 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -252,7 +252,6 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A 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}") @@ -273,7 +272,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) - logger.debug(msg) + logging.getLogger(module_name).debug(msg) # PEP 562 -- Module __getattr__ and __dir__ # specifies that __getattr__ should raise AttributeError if the attribute is not # found. diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index cfe3b78ebab..52e779a3608 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -29,7 +29,7 @@ from .deprecation import ( dir_with_deprecated_constants, ) from .frame import report -from .json import JSON_DUMP, find_paths_unserializable_data +from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -277,11 +277,11 @@ class DeviceEntry: } @cached_property - def json_repr(self) -> str | None: + def json_repr(self) -> bytes | None: """Return a cached JSON representation of the entry.""" try: dict_repr = self.dict_repr - return JSON_DUMP(dict_repr) + return json_bytes(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index c2c9a04b7c3..7ad9caa5a93 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -23,7 +23,7 @@ def async_create_flow( dispatcher: FlowDispatcher | None = None if DISCOVERY_FLOW_DISPATCHER in hass.data: dispatcher = hass.data[DISCOVERY_FLOW_DISPATCHER] - elif hass.state != CoreState.running: + elif hass.state is not CoreState.running: dispatcher = hass.data[DISCOVERY_FLOW_DISPATCHER] = FlowDispatcher(hass) dispatcher.async_setup() diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 07112226ecf..59d680a60ee 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,30 +2,73 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from dataclasses import dataclass from functools import partial import logging -from typing import Any +from typing import Any, Generic, TypeVarTuple, overload from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" + +@dataclass(frozen=True) +class SignalType(Generic[*_Ts]): + """Generic string class for signal to improve typing.""" + + name: str + + def __hash__(self) -> int: + """Return hash of name.""" + + return hash(self.name) + + def __eq__(self, other: Any) -> bool: + """Check equality for dict keys to be compatible with str.""" + + if isinstance(other, str): + return self.name == other + if isinstance(other, SignalType): + return self.name == other.name + return False + + _DispatcherDataType = dict[ - str, + SignalType[*_Ts] | str, dict[ - Callable[..., Any], + Callable[[*_Ts], Any] | Callable[..., Any], HassJob[..., None | Coroutine[Any, Any, None]] | None, ], ] +@overload +@bind_hass +def dispatcher_connect( + hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None] +) -> Callable[[], None]: + ... + + +@overload @bind_hass def dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., None] +) -> Callable[[], None]: + ... + + +@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def +def dispatcher_connect( + hass: HomeAssistant, + signal: SignalType[*_Ts], + target: Callable[[*_Ts], None], ) -> Callable[[], None]: """Connect a callable function to a signal.""" async_unsub = run_callback_threadsafe( @@ -41,9 +84,9 @@ def dispatcher_connect( @callback def _async_remove_dispatcher( - dispatchers: _DispatcherDataType, - signal: str, - target: Callable[..., Any], + dispatchers: _DispatcherDataType[*_Ts], + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], ) -> None: """Remove signal listener.""" try: @@ -59,10 +102,30 @@ def _async_remove_dispatcher( _LOGGER.warning("Unable to remove unknown dispatcher %s", target) +@overload +@callback +@bind_hass +def async_dispatcher_connect( + hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], Any] +) -> Callable[[], None]: + ... + + +@overload @callback @bind_hass def async_dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., Any] +) -> Callable[[], None]: + ... + + +@callback +@bind_hass +def async_dispatcher_connect( + hass: HomeAssistant, + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], ) -> Callable[[], None]: """Connect a callable function to a signal. @@ -71,7 +134,7 @@ def async_dispatcher_connect( if DATA_DISPATCHER not in hass.data: hass.data[DATA_DISPATCHER] = {} - dispatchers: _DispatcherDataType = hass.data[DATA_DISPATCHER] + dispatchers: _DispatcherDataType[*_Ts] = hass.data[DATA_DISPATCHER] if signal not in dispatchers: dispatchers[signal] = {} @@ -84,13 +147,29 @@ def async_dispatcher_connect( return partial(_async_remove_dispatcher, dispatchers, signal, target) +@overload +@bind_hass +def dispatcher_send(hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts) -> None: + ... + + +@overload @bind_hass def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: + ... + + +@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def +def dispatcher_send(hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts) -> None: """Send signal and data.""" hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) -def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: +def _format_err( + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], + *args: Any, +) -> str: """Format error message.""" return "Exception in {} when dispatching '{}': {}".format( # Functions wrapped in partial do not have a __name__ @@ -101,7 +180,7 @@ def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: def _generate_job( - signal: str, target: Callable[..., Any] + signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any] ) -> HassJob[..., None | Coroutine[Any, Any, None]]: """Generate a HassJob for a signal and target.""" return HassJob( @@ -110,16 +189,34 @@ def _generate_job( ) +@overload +@callback +@bind_hass +def async_dispatcher_send( + hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts +) -> None: + ... + + +@overload @callback @bind_hass def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: + ... + + +@callback +@bind_hass +def async_dispatcher_send( + hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts +) -> None: """Send signal and data. This method must be run in the event loop. """ if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: return - dispatchers: _DispatcherDataType = maybe_dispatchers + dispatchers: _DispatcherDataType[*_Ts] = maybe_dispatchers if (target_list := dispatchers.get(signal)) is None: return diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1f3f96f300c..32aa97ab8fe 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -6,7 +6,6 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping import dataclasses -from datetime import timedelta from enum import Enum, IntFlag, auto import functools as ft import logging @@ -44,7 +43,13 @@ from homeassistant.const import ( 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, @@ -82,7 +87,7 @@ FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) # 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 +CONTEXT_RECENT_TIME_SECONDS = 5 # Time that a context is considered recent @callback @@ -246,6 +251,7 @@ class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): 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 @@ -433,6 +439,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "state", "supported_features", "translation_key", + "translation_placeholders", "unique_id", "unit_of_measurement", } @@ -477,6 +484,9 @@ class Entity( # 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 @@ -541,6 +551,7 @@ class Entity( _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 @@ -632,6 +643,29 @@ class Entity( 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, @@ -647,7 +681,7 @@ class Entity( ): 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(): @@ -857,6 +891,16 @@ class Entity( 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 @@ -1119,8 +1163,7 @@ class Entity( if ( self._context_set is not None - and hass.loop.time() - self._context_set - > CONTEXT_RECENT_TIME.total_seconds() + and hass.loop.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS ): self._context = None self._context_set = None diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 30e892a8840..5020c5c4271 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from datetime import timedelta +from functools import partial from itertools import chain import logging from types import ModuleType @@ -20,8 +21,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( - EntityServiceResponse, Event, + HassJob, HomeAssistant, ServiceCall, ServiceResponse, @@ -89,12 +90,13 @@ class EntityComponent(Generic[_EntityT]): 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 +107,11 @@ class EntityComponent(Generic[_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. @@ -231,13 +226,16 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) + async def handle_service( call: ServiceCall, ) -> ServiceResponse: """Handle the service.""" result = await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features + self.hass, self._entities, service_func, call, required_features ) if result: @@ -265,16 +263,21 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service( - call: ServiceCall, - ) -> EntityServiceResponse | None: - """Handle the service.""" - return await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features - ) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) self.hass.services.async_register( - self.domain, name, handle_service, schema, supports_response + self.domain, + name, + partial( + service.entity_service_call, + self.hass, + self._entities, + service_func, + required_features=required_features, + ), + schema, + supports_response, ) async def async_setup_platform( @@ -379,7 +382,7 @@ class EntityComponent(Generic[_EntityT]): if scan_interval is None: scan_interval = self.scan_interval - return EntityPlatform( + entity_platform = EntityPlatform( hass=self.hass, logger=self.logger, domain=self.domain, @@ -388,6 +391,8 @@ class EntityComponent(Generic[_EntityT]): scan_interval=scan_interval, entity_namespace=entity_namespace, ) + entity_platform.async_prepare() + return entity_platform async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 221203902c5..db2760d554c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from contextvars import ContextVar from datetime import datetime, timedelta +from functools import partial from logging import Logger, getLogger from typing import TYPE_CHECKING, Any, Protocol @@ -20,7 +21,7 @@ from homeassistant.core import ( CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, - EntityServiceResponse, + HassJob, HomeAssistant, ServiceCall, SupportsResponse, @@ -55,6 +56,8 @@ SLOW_ADD_MIN_TIMEOUT = 500 PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" +DATA_DOMAIN_ENTITIES = "domain_entities" +DATA_DOMAIN_PLATFORM_ENTITIES = "domain_platform_entities" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -122,6 +125,8 @@ class EntityPlatform: self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None + # Storage for entities for this specific platform only + # which are indexed by entity_id self.entities: dict[str, Entity] = {} self.component_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {} @@ -143,9 +148,24 @@ class EntityPlatform: # which powers entity_component.add_entities self.parallel_updates_created = platform is None - hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( - self.platform_name, [] - ).append(self) + # Storage for entities indexed by domain + # with the child dict indexed by entity_id + # + # This is usually media_player, light, switch, etc. + domain_entities: dict[str, dict[str, Entity]] = hass.data.setdefault( + DATA_DOMAIN_ENTITIES, {} + ) + self.domain_entities = domain_entities.setdefault(domain, {}) + + # Storage for entities indexed by domain and platform + # with the child dict indexed by entity_id + # + # This is usually media_player.yamaha, light.hue, switch.tplink, etc. + domain_platform_entities: dict[ + tuple[str, str], dict[str, Entity] + ] = hass.data.setdefault(DATA_DOMAIN_PLATFORM_ENTITIES, {}) + key = (domain, platform_name) + self.domain_platform_entities = domain_platform_entities.setdefault(key, {}) def __repr__(self) -> str: """Represent an EntityPlatform.""" @@ -304,44 +324,8 @@ class EntityPlatform: logger = self.logger hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - async def get_translations( - language: str, category: str, integration: str - ) -> dict[str, Any]: - """Get entity translations.""" - try: - return await translation.async_get_translations( - hass, language, category, {integration} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - self.component_translations = await get_translations( - hass.config.language, "entity_component", self.domain - ) - self.platform_translations = await get_translations( - hass.config.language, "entity", self.platform_name - ) - if object_id_language == hass.config.language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await get_translations( - object_id_language, "entity", self.platform_name - ) + await self.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -395,7 +379,7 @@ class EntityPlatform: self._async_cancel_retry_setup = None await self._async_setup_platform(async_create_setup_task, tries) - if hass.state == CoreState.running: + if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( hass, wait_time, setup_again ) @@ -424,6 +408,48 @@ class EntityPlatform: finally: warn_task.cancel() + async def _async_get_translations( + self, language: str, category: str, integration: str + ) -> dict[str, Any]: + """Get translations for a language, category, and integration.""" + try: + return await translation.async_get_translations( + self.hass, language, category, {integration} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + config_language = hass.config.language + self.component_translations = await self._async_get_translations( + config_language, "entity_component", self.domain + ) + self.platform_translations = await self._async_get_translations( + config_language, "entity", self.platform_name + ) + if object_id_language == config_language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await self._async_get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await self._async_get_translations( + object_id_language, "entity", self.platform_name + ) + def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -734,6 +760,8 @@ class EntityPlatform: entity_id = entity.entity_id self.entities[entity_id] = entity + self.domain_entities[entity_id] = entity + self.domain_platform_entities[entity_id] = entity if not restored: # Reserve the state in the state machine @@ -746,6 +774,8 @@ class EntityPlatform: def remove_entity_cb() -> None: """Remove entity from entities dict.""" self.entities.pop(entity_id) + self.domain_entities.pop(entity_id) + self.domain_platform_entities.pop(entity_id) entity.async_on_remove(remove_entity_cb) @@ -775,6 +805,13 @@ class EntityPlatform: self._async_unsub_polling() self._async_unsub_polling = None + @callback + def async_prepare(self) -> None: + """Register the entity platform in DATA_ENTITY_PLATFORM.""" + self.hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( + self.platform_name, [] + ).append(self) + async def async_destroy(self) -> None: """Destroy an entity platform. @@ -826,22 +863,21 @@ class EntityPlatform: if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - 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 - ], - func, - call, - required_features, - ) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) self.hass.services.async_register( - self.platform_name, name, handle_service, schema, supports_response + self.platform_name, + name, + partial( + service.entity_service_call, + self.hass, + self.domain_platform_entities, + service_func, + required_features=required_features, + ), + schema, + supports_response, ) async def _update_entity_states(self, now: datetime) -> None: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 65ae1a8e9e5..b6790ff0dc3 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -51,7 +51,7 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from .json import JSON_DUMP, find_paths_unserializable_data +from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -133,9 +133,10 @@ EventEntityRegistryUpdatedData = ( EntityOptionsType = Mapping[str, Mapping[str, Any]] ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] -DISLAY_DICT_OPTIONAL = ( +DISPLAY_DICT_OPTIONAL = ( ("ai", "area_id"), ("di", "device_id"), + ("ic", "icon"), ("tk", "translation_key"), ) @@ -208,7 +209,7 @@ class RegistryEntry: Returns None if there's no data needed for display. """ display_dict: dict[str, Any] = {"ei": self.entity_id, "pl": self.platform} - for key, attr_name in DISLAY_DICT_OPTIONAL: + for key, attr_name in DISPLAY_DICT_OPTIONAL: if (attr_val := getattr(self, attr_name)) is not None: display_dict[key] = attr_val if (category := self.entity_category) is not None: @@ -227,14 +228,14 @@ class RegistryEntry: return display_dict @cached_property - def display_json_repr(self) -> str | None: + def display_json_repr(self) -> bytes | None: """Return a cached partial JSON representation of the entry. This version only includes what's needed for display. """ try: dict_repr = self._as_display_dict - json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None + json_repr: bytes | None = json_bytes(dict_repr) if dict_repr else None return json_repr except (ValueError, TypeError): _LOGGER.error( @@ -282,11 +283,11 @@ class RegistryEntry: } @cached_property - def partial_json_repr(self) -> str | None: + def partial_json_repr(self) -> bytes | None: """Return a cached partial JSON representation of the entry.""" try: dict_repr = self.as_partial_dict - return JSON_DUMP(dict_repr) + return json_bytes(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -436,9 +437,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): class EntityRegistryItems(UserDict[str, RegistryEntry]): """Container for entity registry items, maps entity_id -> entry. - Maintains two additional indexes: + Maintains four additional indexes: - id -> entry - (domain, platform, unique_id) -> entity_id + - config_entry_id -> list[key] + - device_id -> list[key] """ def __init__(self) -> None: @@ -446,6 +449,8 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): super().__init__() self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} + self._config_entry_id_index: dict[str, list[str]] = {} + self._device_id_index: dict[str, list[str]] = {} def values(self) -> ValuesView[RegistryEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -455,18 +460,34 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """Add an item.""" data = self.data if key in data: - old_entry = data[key] - del self._entry_ids[old_entry.id] - del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] + self._unindex_entry(key) data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + if (config_entry_id := entry.config_entry_id) is not None: + self._config_entry_id_index.setdefault(config_entry_id, []).append(key) + if (device_id := entry.device_id) is not None: + self._device_id_index.setdefault(device_id, []).append(key) + + def _unindex_entry(self, key: str) -> None: + """Unindex an entry.""" + entry = self.data[key] + del self._entry_ids[entry.id] + del self._index[(entry.domain, entry.platform, entry.unique_id)] + if (config_entry_id := entry.config_entry_id) is not None: + entries = self._config_entry_id_index[config_entry_id] + entries.remove(key) + if not entries: + del self._config_entry_id_index[config_entry_id] + if (device_id := entry.device_id) is not None: + entries = self._device_id_index[device_id] + entries.remove(key) + if not entries: + del self._device_id_index[device_id] def __delitem__(self, key: str) -> None: """Remove an item.""" - entry = self[key] - del self._entry_ids[entry.id] - del self._index[(entry.domain, entry.platform, entry.unique_id)] + self._unindex_entry(key) super().__delitem__(key) def get_entity_id(self, key: tuple[str, str, str]) -> str | None: @@ -477,6 +498,19 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """Get entry from id.""" return self._entry_ids.get(key) + def get_entries_for_device_id(self, device_id: str) -> list[RegistryEntry]: + """Get entries for device.""" + return [self.data[key] for key in self._device_id_index.get(device_id, ())] + + def get_entries_for_config_entry_id( + self, config_entry_id: str + ) -> list[RegistryEntry]: + """Get entries for config entry.""" + return [ + self.data[key] + for key in self._config_entry_id_index.get(config_entry_id, ()) + ] + class EntityRegistry: """Class to hold a registry of entities.""" @@ -1217,9 +1251,8 @@ def async_entries_for_device( """Return entries that match a device.""" return [ entry - for entry in registry.entities.values() - if entry.device_id == device_id - and (not entry.disabled_by or include_disabled_entities) + for entry in registry.entities.get_entries_for_device_id(device_id) + if (not entry.disabled_by or include_disabled_entities) ] @@ -1236,11 +1269,7 @@ def async_entries_for_config_entry( registry: EntityRegistry, config_entry_id: str ) -> list[RegistryEntry]: """Return entries that match a config entry.""" - return [ - entry - for entry in registry.entities.values() - if entry.config_entry_id == config_entry_id - ] + return registry.entities.get_entries_for_config_entry_id(config_entry_id) @callback diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 02add8ff012..d3f4144a293 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -10,7 +10,7 @@ import functools as ft import logging from random import randint import time -from typing import Any, Concatenate, ParamSpec, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypedDict, TypeVar import attr @@ -1389,6 +1389,45 @@ def async_track_point_in_time( track_point_in_time = threaded_listener_factory(async_track_point_in_time) +@dataclass(slots=True) +class _TrackPointUTCTime: + hass: HomeAssistant + job: HassJob[[datetime], Coroutine[Any, Any, None] | None] + utc_point_in_time: datetime + expected_fire_timestamp: float + _cancel_callback: asyncio.TimerHandle | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + loop = self.hass.loop + self._cancel_callback = loop.call_at( + loop.time() + self.expected_fire_timestamp - time.time(), self._run_action + ) + + @callback + def _run_action(self) -> None: + """Call the action.""" + # Depending on the available clock support (including timer hardware + # and the OS kernel) it can happen that we fire a little bit too early + # as measured by utcnow(). That is bad when callbacks have assumptions + # about the current time. Thus, we rearm the timer for the remaining + # time. + if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0: + _LOGGER.debug("Called %f seconds too early, rearming", delta) + loop = self.hass.loop + self._cancel_callback = loop.call_at(loop.time() + delta, self._run_action) + return + + self.hass.async_run_hass_job(self.job, self.utc_point_in_time) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback.cancel() + + @callback @bind_hass def async_track_point_in_utc_time( @@ -1404,44 +1443,14 @@ def async_track_point_in_utc_time( # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) - - # Since this is called once, we accept a HassJob so we can avoid - # having to figure out how to call the action every time its called. - cancel_callback: asyncio.TimerHandle | None = None - loop = hass.loop - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - nonlocal cancel_callback - # Depending on the available clock support (including timer hardware - # and the OS kernel) it can happen that we fire a little bit too early - # as measured by utcnow(). That is bad when callbacks have assumptions - # about the current time. Thus, we rearm the timer for the remaining - # time. - if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: - _LOGGER.debug("Called %f seconds too early, rearming", delta) - - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - return - - hass.async_run_hass_job(job, utc_point_in_time) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"track point in utc time {utc_point_in_time}") ) - delta = expected_fire_timestamp - time.time() - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - - @callback - def unsub_point_in_time_listener() -> None: - """Cancel the call_at.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_point_in_time_listener + track = _TrackPointUTCTime(hass, job, utc_point_in_time, expected_fire_timestamp) + track.async_attach() + return track.async_cancel track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) @@ -1500,6 +1509,61 @@ def async_call_later( call_later = threaded_listener_factory(async_call_later) +@dataclass(slots=True) +class _TrackTimeInterval: + """Helper class to help listen to time interval events.""" + + hass: HomeAssistant + seconds: float + job_name: str + action: Callable[[datetime], Coroutine[Any, Any, None] | None] + cancel_on_shutdown: bool | None + _track_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _run_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _cancel_callback: CALLBACK_TYPE | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + hass = self.hass + self._track_job = HassJob( + self._interval_listener, + self.job_name, + job_type=HassJobType.Callback, + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._run_job = HassJob( + self.action, + f"track time interval {self.seconds}", + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + + @callback + def _interval_listener(self, now: datetime) -> None: + """Handle elapsed intervals.""" + if TYPE_CHECKING: + assert self._run_job is not None + assert self._track_job is not None + hass = self.hass + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + hass.async_run_hass_job(self._run_job, now) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback() + + @callback @bind_hass def async_track_time_interval( @@ -1514,41 +1578,13 @@ def async_track_time_interval( The listener is passed the time it fires in UTC time. """ - remove: CALLBACK_TYPE - interval_listener_job: HassJob[[datetime], None] - interval_seconds = interval.total_seconds() - - job = HassJob( - action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown - ) - - @callback - def interval_listener(now: datetime) -> None: - """Handle elapsed intervals.""" - nonlocal remove - nonlocal interval_listener_job - - remove = async_call_later(hass, interval_seconds, interval_listener_job) - hass.async_run_hass_job(job, now) - + seconds = interval.total_seconds() + job_name = f"track time interval {seconds} {action}" if name: - job_name = f"{name}: track time interval {interval} {action}" - else: - job_name = f"track time interval {interval} {action}" - - interval_listener_job = HassJob( - interval_listener, - job_name, - cancel_on_shutdown=cancel_on_shutdown, - job_type=HassJobType.Callback, - ) - remove = async_call_later(hass, interval_seconds, interval_listener_job) - - def remove_listener() -> None: - """Remove interval listener.""" - remove() - - return remove_listener + job_name = f"{name}: {job_name}" + track = _TrackTimeInterval(hass, seconds, job_name, action, cancel_on_shutdown) + track.async_attach() + return track.async_cancel track_time_interval = threaded_listener_factory(async_track_time_interval) @@ -1650,6 +1686,62 @@ time_tracker_utcnow = dt_util.utcnow time_tracker_timestamp = time.time +@dataclass(slots=True) +class _TrackUTCTimeChange: + hass: HomeAssistant + time_match_expression: tuple[list[int], list[int], list[int]] + microsecond: int + local: bool + job: HassJob[[datetime], Coroutine[Any, Any, None] | None] + listener_job_name: str + _pattern_time_change_listener_job: HassJob[[datetime], None] | None = None + _cancel_callback: CALLBACK_TYPE | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + self._pattern_time_change_listener_job = HassJob( + self._pattern_time_change_listener, + self.listener_job_name, + job_type=HassJobType.Callback, + ) + self._cancel_callback = async_track_point_in_utc_time( + self.hass, + self._pattern_time_change_listener_job, + self._calculate_next(dt_util.utcnow()), + ) + + def _calculate_next(self, utc_now: datetime) -> datetime: + """Calculate and set the next time the trigger should fire.""" + localized_now = dt_util.as_local(utc_now) if self.local else utc_now + return dt_util.find_next_time_expression_time( + localized_now, *self.time_match_expression + ).replace(microsecond=self.microsecond) + + @callback + def _pattern_time_change_listener(self, _: datetime) -> None: + """Listen for matching time_changed events.""" + hass = self.hass + # Fetch time again because we want the actual time, not the + # time when the timer was scheduled + utc_now = time_tracker_utcnow() + localized_now = dt_util.as_local(utc_now) if self.local else utc_now + hass.async_run_hass_job(self.job, localized_now) + if TYPE_CHECKING: + assert self._pattern_time_change_listener_job is not None + self._cancel_callback = async_track_point_in_utc_time( + hass, + self._pattern_time_change_listener_job, + self._calculate_next(utc_now + timedelta(seconds=1)), + ) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback() + + @callback @bind_hass def async_track_utc_time_change( @@ -1682,49 +1774,17 @@ def async_track_utc_time_change( # since it can create a thundering herd problem # https://github.com/home-assistant/core/issues/82231 microsecond = randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) - - def calculate_next(now: datetime) -> datetime: - """Calculate and set the next time the trigger should fire.""" - localized_now = dt_util.as_local(now) if local else now - return dt_util.find_next_time_expression_time( - localized_now, matching_seconds, matching_minutes, matching_hours - ).replace(microsecond=microsecond) - - time_listener: CALLBACK_TYPE | None = None - pattern_time_change_listener_job: HassJob[[datetime], Any] | None = None - - @callback - def pattern_time_change_listener(_: datetime) -> None: - """Listen for matching time_changed events.""" - nonlocal time_listener - nonlocal pattern_time_change_listener_job - - now = time_tracker_utcnow() - hass.async_run_hass_job(job, dt_util.as_local(now) if local else now) - assert pattern_time_change_listener_job is not None - - time_listener = async_track_point_in_utc_time( - hass, - pattern_time_change_listener_job, - calculate_next(now + timedelta(seconds=1)), - ) - - pattern_time_change_listener_job = HassJob( - pattern_time_change_listener, - f"time change listener {hour}:{minute}:{second} {action}", - job_type=HassJobType.Callback, + listener_job_name = f"time change listener {hour}:{minute}:{second} {action}" + track = _TrackUTCTimeChange( + hass, + (matching_seconds, matching_minutes, matching_hours), + microsecond, + local, + job, + listener_job_name, ) - time_listener = async_track_point_in_utc_time( - hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) - ) - - @callback - def unsub_pattern_time_change_listener() -> None: - """Cancel the time listener.""" - assert time_listener is not None - time_listener() - - return unsub_pattern_time_change_listener + track.async_attach() + return track.async_cancel track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) diff --git a/homeassistant/helpers/group.py b/homeassistant/helpers/group.py new file mode 100644 index 00000000000..437df226118 --- /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/icon.py b/homeassistant/helpers/icon.py index 97e0d20927c..3486925b095 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,165 @@ """Icon helper methods.""" from __future__ import annotations +import asyncio +from collections.abc import Iterable from functools import lru_cache +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations +from homeassistant.util.json import load_json_object + +from .translation import build_resources + +ICON_LOAD_LOCK = "icon_load_lock" +ICON_CACHE = "icon_cache" + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _component_icons_path(component: str, integration: Integration) -> str | None: + """Return the icons json file location for a component. + + Ex: components/hue/icons.json + If component is just a single file, will return None. + """ + domain = component.rpartition(".")[-1] + + # If it's a component that is just one file, we don't support icons + # Example custom_components/my_component.py + if integration.file_path.name != domain: + return None + + return str(integration.file_path / "icons.json") + + +def _load_icons_files(icons_files: dict[str, str]) -> dict[str, dict[str, Any]]: + """Load and parse icons.json files.""" + return { + component: load_json_object(icons_file) + for component, icons_file in icons_files.items() + } + + +async def _async_get_component_icons( + hass: HomeAssistant, + components: set[str], + integrations: dict[str, Integration], +) -> dict[str, Any]: + """Load icons.""" + icons: dict[str, Any] = {} + + # Determine files to load + files_to_load = {} + for loaded in components: + domain = loaded.rpartition(".")[-1] + if (path := _component_icons_path(loaded, integrations[domain])) is None: + icons[loaded] = {} + else: + files_to_load[loaded] = path + + # Load files + if files_to_load and ( + load_icons_job := hass.async_add_executor_job(_load_icons_files, files_to_load) + ): + icons |= await load_icons_job + + return icons + + +class _IconsCache: + """Cache for icons.""" + + __slots__ = ("_hass", "_loaded", "_cache") + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the cache.""" + self._hass = hass + self._loaded: set[str] = set() + self._cache: dict[str, dict[str, Any]] = {} + + async def async_fetch( + self, + category: str, + components: set[str], + ) -> dict[str, dict[str, Any]]: + """Load resources into the cache.""" + if components_to_load := components - self._loaded: + await self._async_load(components_to_load) + + return { + component: result + for component in components + if (result := self._cache.get(category, {}).get(component)) + } + + async def _async_load(self, components: set[str]) -> None: + """Populate the cache for a given set of components.""" + _LOGGER.debug( + "Cache miss for: %s", + ", ".join(components), + ) + + integrations: dict[str, Integration] = {} + domains = list({loaded.rpartition(".")[-1] 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): + raise int_or_exc + integrations[domain] = int_or_exc + + icons = await _async_get_component_icons(self._hass, components, integrations) + + self._build_category_cache(components, icons) + self._loaded.update(components) + + @callback + def _build_category_cache( + self, + components: set[str], + icons: dict[str, dict[str, Any]], + ) -> None: + """Extract resources into the cache.""" + resource: dict[str, Any] | str + categories: set[str] = set() + for resource in icons.values(): + categories.update(resource) + + for category in categories: + new_resources = build_resources(icons, components, category) + for component, resource in new_resources.items(): + category_cache: dict[str, Any] = self._cache.setdefault(category, {}) + category_cache[component] = resource + + +async def async_get_icons( + hass: HomeAssistant, + category: str, + integrations: Iterable[str] | None = None, +) -> dict[str, Any]: + """Return all icons of integrations. + + If integration specified, load it for that one; otherwise default to loaded + intgrations. + """ + lock = hass.data.setdefault(ICON_LOAD_LOCK, asyncio.Lock()) + + if integrations: + components = set(integrations) + else: + components = { + component for component in hass.config.components if "." not in component + } + async with lock: + if ICON_CACHE in hass.data: + cache: _IconsCache = hass.data[ICON_CACHE] + else: + cache = hass.data[ICON_CACHE] = _IconsCache(hass) + + return await cache.async_fetch(category, components) @lru_cache diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 056f972e7f7..fe399659a56 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,8 +1,9 @@ """Module to coordinate user intentions.""" + from __future__ import annotations import asyncio -from collections.abc import Collection, Iterable +from collections.abc import Collection, Coroutine, Iterable import dataclasses from dataclasses import dataclass from enum import Enum @@ -109,8 +110,8 @@ async def async_handle( except vol.Invalid as err: _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err) raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err - except IntentHandleError: - raise + except IntentError: + raise # bubble up intent related errors except Exception as err: raise IntentUnexpectedError(f"Error handling {intent_type}") from err @@ -135,6 +136,25 @@ class IntentUnexpectedError(IntentError): """Unexpected error while handling intent.""" +class NoStatesMatchedError(IntentError): + """Error when no states match the intent's constraints.""" + + def __init__( + self, + name: str | None, + area: str | None, + domains: set[str] | None, + device_classes: set[str] | None, + ) -> None: + """Initialize error.""" + super().__init__() + + self.name = name + self.area = area + self.domains = domains + self.device_classes = device_classes + + def _is_device_class( state: State, entity: entity_registry.RegistryEntry | None, @@ -382,17 +402,21 @@ class ServiceIntentHandler(IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - name: str | None = slots.get("name", {}).get("value") - if name == "all": + name_slot = slots.get("name", {}) + entity_id: str | None = name_slot.get("value") + entity_name: str | None = name_slot.get("text") + if entity_id == "all": # Don't match on name if targeting all entities - name = None + entity_id = None # Look up area first to fail early - area_name = slots.get("area", {}).get("value") + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + area_name = area_slot.get("text") area: area_registry.AreaEntry | None = None - if area_name is not None: + if area_id is not None: areas = area_registry.async_get(hass) - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( + area = areas.async_get_area(area_id) or areas.async_get_area_by_name( area_name ) if area is None: @@ -412,7 +436,7 @@ class ServiceIntentHandler(IntentHandler): states = list( async_match_states( hass, - name=name, + name=entity_id, area=area, domains=domains, device_classes=device_classes, @@ -421,8 +445,12 @@ class ServiceIntentHandler(IntentHandler): ) if not states: - raise IntentHandleError( - f"No entities matched for: name={name}, area={area}, domains={domains}, device_classes={device_classes}", + # No states matched constraints + raise NoStatesMatchedError( + name=entity_name or entity_id, + area=area_name or area_id, + domains=domains, + device_classes=device_classes, ) response = await self.async_handle_states(intent_obj, states, area) @@ -451,7 +479,7 @@ class ServiceIntentHandler(IntentHandler): else: speech_name = states[0].name - service_coros = [] + service_coros: list[Coroutine[Any, Any, None]] = [] for state in states: service_coros.append(self.async_call_service(intent_obj, state)) @@ -507,7 +535,7 @@ class ServiceIntentHandler(IntentHandler): ) ) - async def _run_then_background(self, task: asyncio.Task) -> None: + async def _run_then_background(self, task: asyncio.Task[Any]) -> None: """Run task with timeout to (hopefully) catch validation errors. After the timeout the task will continue to run in the background. diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index e155427fa10..b9862907960 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -45,6 +45,8 @@ def json_encoder_default(obj: Any) -> Any: Hand other objects to the original method. """ + if hasattr(obj, "json_fragment"): + return obj.json_fragment if isinstance(obj, (set, tuple)): return list(obj) if isinstance(obj, float): @@ -114,6 +116,9 @@ def json_bytes_strip_null(data: Any) -> bytes: return json_bytes(_strip_null(orjson.loads(result))) +json_fragment = orjson.Fragment + + def json_dumps(data: Any) -> str: r"""Dump json string. diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 625bab8b218..7df83cd0ab9 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,6 +10,7 @@ from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util +from homeassistant.util.json import json_loads from . import start from .entity import Entity @@ -70,9 +71,9 @@ class StoredState: self.state = state def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the stored state.""" + """Return a dict representation of the stored state to be JSON serialized.""" result = { - "state": self.state.as_dict(), + "state": self.state.json_fragment, "extra_data": self.extra_data.as_dict() if self.extra_data else None, "last_seen": self.last_seen, } @@ -178,8 +179,8 @@ class RestoreStateData: 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,13 +188,12 @@ class RestoreStateData: # 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 @@ -201,7 +201,7 @@ class RestoreStateData: # 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 @@ -271,7 +271,7 @@ class RestoreStateData: # To fully mimic all the attribute data types when loaded from storage, # we're going to serialize it to JSON and then re-load it. if state is not None: - state = State.from_dict(_encode_complex(state.as_dict())) + state = State.from_dict(json_loads(state.as_dict_json)) # type: ignore[arg-type] if state is not None: self.last_states[entity_id] = StoredState( state, extra_data, dt_util.utcnow() @@ -280,32 +280,6 @@ class RestoreStateData: self.entities.pop(entity_id) -def _encode(value: Any) -> Any: - """Little helper to JSON encode a value.""" - try: - return JSONEncoder.default( - None, # type: ignore[arg-type] - value, - ) - except TypeError: - return value - - -def _encode_complex(value: Any) -> Any: - """Recursively encode all values with the JSONEncoder.""" - if isinstance(value, dict): - return {_encode(key): _encode_complex(value) for key, value in value.items()} - if isinstance(value, list): - return [_encode_complex(val) for val in value] - - new_value = _encode(value) - - if isinstance(new_value, type(value)): - return new_value - - return _encode_complex(new_value) - - class RestoreEntity(Entity): """Mixin class for restoring previous entity state.""" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 07f10e13dbf..d1546528ef2 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,7 +12,7 @@ from functools import partial import itertools import logging from types import MappingProxyType -from typing import Any, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast import voluptuous as vol @@ -52,6 +52,7 @@ from homeassistant.const import ( CONF_SERVICE, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, + CONF_SET_CONVERSATION_RESPONSE, CONF_STOP, CONF_TARGET, CONF_THEN, @@ -78,7 +79,7 @@ from homeassistant.util.dt import utcnow from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function -from .dispatcher import async_dispatcher_connect, async_dispatcher_send +from .dispatcher import SignalType, async_dispatcher_connect, async_dispatcher_send from .event import async_call_later, async_track_template from .script_variables import ScriptVariables from .trace import ( @@ -98,7 +99,13 @@ from .trace import ( trace_update_result, ) from .trigger import async_initialize_triggers, async_validate_trigger_config -from .typing import ConfigType +from .typing import UNDEFINED, ConfigType, UndefinedType + +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs @@ -142,7 +149,7 @@ _SHUTDOWN_MAX_WAIT = 60 ACTION_TRACE_NODE_MAX_LEN = 20 # Max length of a trace node for repeated actions -SCRIPT_BREAKPOINT_HIT = "script_breakpoint_hit" +SCRIPT_BREAKPOINT_HIT = SignalType[str, str, str]("script_breakpoint_hit") SCRIPT_DEBUG_CONTINUE_STOP = "script_debug_continue_stop_{}_{}" SCRIPT_DEBUG_CONTINUE_ALL = "script_debug_continue_all" @@ -252,13 +259,14 @@ def make_script_schema( STATIC_VALIDATION_ACTION_TYPES = ( + cv.SCRIPT_ACTION_ACTIVATE_SCENE, cv.SCRIPT_ACTION_CALL_SERVICE, cv.SCRIPT_ACTION_DELAY, - cv.SCRIPT_ACTION_WAIT_TEMPLATE, cv.SCRIPT_ACTION_FIRE_EVENT, - cv.SCRIPT_ACTION_ACTIVATE_SCENE, - cv.SCRIPT_ACTION_VARIABLES, + cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE, cv.SCRIPT_ACTION_STOP, + cv.SCRIPT_ACTION_VARIABLES, + cv.SCRIPT_ACTION_WAIT_TEMPLATE, ) @@ -385,6 +393,7 @@ class _ScriptRun: self._step = -1 self._stop = asyncio.Event() self._stopped = asyncio.Event() + self._conversation_response: str | None | UndefinedType = UNDEFINED def _changed(self) -> None: if not self._stop.is_set(): @@ -450,7 +459,7 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(response, self._variables) + return ScriptRunResult(self._conversation_response, response, self._variables) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -474,11 +483,12 @@ class _ScriptRun: 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 ) + finally: + trace_element.update_variables(self._variables) def _finish(self) -> None: self._script._runs.remove(self) # pylint: disable=protected-access @@ -1031,6 +1041,18 @@ class _ScriptRun: self._hass, self._variables, render_as_defaults=False ) + async def _async_set_conversation_response_step(self): + """Set conversation response.""" + self._step_log("setting conversation response") + resp: template.Template | None = self._action[CONF_SET_CONVERSATION_RESPONSE] + if resp is None: + self._conversation_response = None + else: + self._conversation_response = resp.async_render( + variables=self._variables, parse_result=False + ) + trace_set_result(conversation_response=self._conversation_response) + async def _async_stop_step(self): """Stop script execution.""" stop = self._action[CONF_STOP] @@ -1075,11 +1097,13 @@ class _ScriptRun: async def _async_run_script(self, script: Script) -> None: """Execute a script.""" - await self._async_run_long_action( + result = await self._async_run_long_action( self._hass.async_create_task( script.async_run(self._variables, self._context) ) ) + if result and result.conversation_response is not UNDEFINED: + self._conversation_response = result.conversation_response class _QueuedScriptRun(_ScriptRun): @@ -1202,6 +1226,7 @@ class _IfData(TypedDict): class ScriptRunResult: """Container with the result of a script run.""" + conversation_response: str | None | UndefinedType service_response: ServiceResponse variables: dict @@ -1270,9 +1295,6 @@ class Script: self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} - self._referenced_entities: set[str] | None = None - self._referenced_devices: set[str] | None = None - self._referenced_areas: set[str] | None = None self.variables = variables self._variables_dynamic = template.is_complex(variables) if self._variables_dynamic: @@ -1343,15 +1365,12 @@ class Script: """Return true if the current mode support max.""" return self.script_mode in (SCRIPT_MODE_PARALLEL, SCRIPT_MODE_QUEUED) - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" - if self._referenced_areas is not None: - return self._referenced_areas - - self._referenced_areas = set() - Script._find_referenced_areas(self._referenced_areas, self.sequence) - return self._referenced_areas + referenced_areas: set[str] = set() + Script._find_referenced_areas(referenced_areas, self.sequence) + return referenced_areas @staticmethod def _find_referenced_areas( @@ -1383,15 +1402,12 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_areas(referenced, script[CONF_SEQUENCE]) - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - if self._referenced_devices is not None: - return self._referenced_devices - - self._referenced_devices = set() - Script._find_referenced_devices(self._referenced_devices, self.sequence) - return self._referenced_devices + referenced_devices: set[str] = set() + Script._find_referenced_devices(referenced_devices, self.sequence) + return referenced_devices @staticmethod def _find_referenced_devices( @@ -1433,15 +1449,12 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" - if self._referenced_entities is not None: - return self._referenced_entities - - self._referenced_entities = set() - Script._find_referenced_entities(self._referenced_entities, self.sequence) - return self._referenced_entities + referenced_entities: set[str] = set() + Script._find_referenced_entities(referenced_entities, self.sequence) + return referenced_entities @staticmethod def _find_referenced_entities( @@ -1793,7 +1806,9 @@ class Script: @callback -def breakpoint_clear(hass, key, run_id, node): +def breakpoint_clear( + hass: HomeAssistant, key: str, run_id: str | None, node: str +) -> None: """Clear a breakpoint.""" run_id = run_id or RUN_ID_ANY breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] @@ -1809,7 +1824,9 @@ def breakpoint_clear_all(hass: HomeAssistant) -> None: @callback -def breakpoint_set(hass, key, run_id, node): +def breakpoint_set( + hass: HomeAssistant, key: str, run_id: str | None, node: str +) -> None: """Set a breakpoint.""" run_id = run_id or RUN_ID_ANY breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] @@ -1834,7 +1851,7 @@ def breakpoint_list(hass: HomeAssistant) -> list[dict[str, Any]]: @callback -def debug_continue(hass, key, run_id): +def debug_continue(hass: HomeAssistant, key: str, run_id: str) -> None: """Continue execution of a halted script.""" # Clear any wildcard breakpoint breakpoint_clear(hass, key, run_id, NODE_ANY) @@ -1844,7 +1861,7 @@ def debug_continue(hass, key, run_id): @callback -def debug_step(hass, key, run_id): +def debug_step(hass: HomeAssistant, key: str, run_id: str) -> None: """Single step a halted script.""" # Set a wildcard breakpoint breakpoint_set(hass, key, run_id, NODE_ANY) @@ -1854,7 +1871,7 @@ def debug_step(hass, key, run_id): @callback -def debug_stop(hass, key, run_id): +def debug_stop(hass: HomeAssistant, key: str, run_id: str) -> None: """Stop execution of a running or halted script.""" signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) async_dispatcher_send(hass, signal, "stop") diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9c4266583e8..8f2d9bf4938 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -565,6 +565,49 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): return self.config["value"] +class QrErrorCorrectionLevel(StrEnum): + """Possible error correction levels for QR code selector.""" + + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class QrCodeSelectorConfig(TypedDict, total=False): + """Class to represent a QR code selector config.""" + + data: str + scale: int + error_correction_level: QrErrorCorrectionLevel + + +@SELECTORS.register("qr_code") +class QrCodeSelector(Selector[QrCodeSelectorConfig]): + """QR code selector.""" + + selector_type = "qr_code" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Required("data"): str, + vol.Optional("scale"): int, + vol.Optional("error_correction_level"): vol.All( + vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value + ), + } + ) + + def __init__(self, config: QrCodeSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(vol.Any(str, None))(data) + return self.config["data"] + + class ConversationAgentSelectorConfig(TypedDict, total=False): """Class to represent a conversation agent selector config.""" @@ -879,9 +922,9 @@ class LocationSelector(Selector[LocationSelectorConfig]): ) DATA_SCHEMA = vol.Schema( { - vol.Required("latitude"): float, - vol.Required("longitude"): float, - vol.Optional("radius"): float, + vol.Required("latitude"): vol.Coerce(float), + vol.Required("longitude"): vol.Coerce(float), + vol.Optional("radius"): vol.Coerce(float), } ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9af69acc6b2..30516e3a099 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Iterable +from collections.abc import Awaitable, Callable, Iterable import dataclasses from enum import Enum -from functools import cache, partial, wraps +from functools import cache, partial import logging from types import ModuleType from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast @@ -29,6 +29,7 @@ from homeassistant.const import ( from homeassistant.core import ( Context, EntityServiceResponse, + HassJob, HomeAssistant, ServiceCall, ServiceResponse, @@ -53,12 +54,12 @@ from . import ( 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) @@ -191,11 +192,14 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" + __slots__ = ("entity_ids", "device_ids", "area_ids") + def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" - entity_ids: str | list | None = service_call.data.get(ATTR_ENTITY_ID) - device_ids: str | list | None = service_call.data.get(ATTR_DEVICE_ID) - area_ids: str | list | None = service_call.data.get(ATTR_AREA_ID) + service_call_data = service_call.data + entity_ids: str | list | None = service_call_data.get(ATTR_ENTITY_ID) + device_ids: str | list | None = service_call_data.get(ATTR_DEVICE_ID) + area_ids: str | list | None = service_call_data.get(ATTR_AREA_ID) self.entity_ids = ( set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() @@ -460,9 +464,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) @@ -516,7 +520,7 @@ def async_extract_referenced_entity_ids( @bind_hass async def async_extract_config_entry_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True -) -> set: +) -> set[str]: """Extract referenced config entry ids from a service call.""" referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) ent_reg = entity_registry.async_get(hass) @@ -577,31 +581,41 @@ async def async_get_all_descriptions( descriptions_cache: dict[ tuple[str, str], dict[str, Any] | None ] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - services = hass.services.async_services() + + # We don't mutate services here so we avoid calling + # async_services which makes a copy of every services + # dict. + services = hass.services.async_services_internal() # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. - missing = set() - all_services = [] - for domain in services: - for service_name in services[domain]: + domains_with_missing_services: set[str] = set() + all_services: set[tuple[str, str]] = set() + for domain, services_by_domain in services.items(): + for service_name in services_by_domain: cache_key = (domain, service_name) - all_services.append(cache_key) + all_services.add(cache_key) if cache_key not in descriptions_cache: - missing.add(domain) + domains_with_missing_services.add(domain) # If we have a complete cache, check if it is still valid + all_cache: tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] | None if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache if previous_all_services == all_services: - return cast(dict[str, dict[str, Any]], previous_descriptions_cache) + return previous_descriptions_cache # type: ignore[no-any-return] # Files we loaded for missing descriptions loaded: dict[str, JSON_TYPE] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new services get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + services = {domain: service.copy() for domain, service in services.items()} - if missing: - ints_or_excs = await async_get_integrations(hass, missing) + if domains_with_missing_services: + ints_or_excs = await async_get_integrations(hass, domains_with_missing_services) integrations: list[Integration] = [] for domain, int_or_exc in ints_or_excs.items(): if type(int_or_exc) is Integration: # noqa: E721 @@ -613,11 +627,11 @@ async def async_get_all_descriptions( contents = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(missing, contents)) + loaded = dict(zip(domains_with_missing_services, contents)) # Load translations for all service domains translations = await translation.async_get_translations( - hass, "en", "services", list(services) + hass, "en", "services", services ) # Build response @@ -741,7 +755,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, @@ -754,9 +768,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 @@ -771,30 +784,27 @@ 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], - func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], + registered_entities: dict[str, Entity], + func: str | HassJob, call: ServiceCall, required_features: Iterable[int] | None = None, ) -> EntityServiceResponse | None: @@ -832,7 +842,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, @@ -930,7 +940,7 @@ async def entity_service_call( async def _handle_entity_call( hass: HomeAssistant, entity: Entity, - func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], + func: str | HassJob, data: dict | ServiceCall, context: Context, ) -> ServiceResponse: @@ -939,11 +949,11 @@ async def _handle_entity_call( task: asyncio.Future[ServiceResponse] | None if isinstance(func, str): - task = hass.async_run_job( - partial(getattr(entity, func), **data) # type: ignore[arg-type] + task = hass.async_run_hass_job( + HassJob(partial(getattr(entity, func), **data)) # type: ignore[arg-type] ) else: - task = hass.async_run_job(func, entity, data) + task = hass.async_run_hass_job(func, entity, data) # Guard because callback functions do not return a task when passed to # async_run_job. @@ -965,6 +975,24 @@ async def _handle_entity_call( return result +async def _async_admin_handler( + hass: HomeAssistant, + service_job: HassJob[[None], Callable[[ServiceCall], Awaitable[None] | None]], + call: ServiceCall, +) -> None: + """Run an admin service.""" + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + if not user.is_admin: + raise Unauthorized(context=call.context) + + result = hass.async_run_hass_job(service_job, call) + if result is not None: + await result + + @bind_hass @callback def async_register_admin_service( @@ -975,21 +1003,16 @@ def async_register_admin_service( schema: vol.Schema = vol.Schema({}, extra=vol.PREVENT_EXTRA), ) -> None: """Register a service that requires admin access.""" - - @wraps(service_func) - async def admin_handler(call: ServiceCall) -> None: - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - result = hass.async_run_job(service_func, call) - if result is not None: - await result - - hass.services.async_register(domain, service, admin_handler, schema) + hass.services.async_register( + domain, + service, + partial( + _async_admin_handler, + hass, + HassJob(service_func, f"admin service {domain}.{service}"), + ), + schema, + ) @bind_hass diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index fe3bd2b0987..30e8466070e 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -81,7 +81,7 @@ def async_at_started( """ def _is_started(hass: HomeAssistant) -> bool: - return hass.state == CoreState.running + return hass.state is CoreState.running return _async_at_core_state( hass, at_start_cb, EVENT_HOMEASSISTANT_STARTED, _is_started diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 0e92cc6ff01..f789aeb37e4 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -260,7 +260,7 @@ class Store(Generic[_T]): "data": data, } - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: self._async_ensure_final_write_listener() return @@ -286,7 +286,7 @@ class Store(Generic[_T]): self._async_cleanup_delay_listener() self._async_ensure_final_write_listener() - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: return self._unsub_delay_listener = async_call_later( @@ -318,7 +318,7 @@ class Store(Generic[_T]): async def _async_callback_delayed_write(self, _now): """Handle a delayed write callback.""" # catch the case where a call is scheduled and then we stop Home Assistant - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: self._async_ensure_final_write_listener() return await self._async_handle_write_data() diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index 5a35f1bee13..15d38063f63 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -24,17 +24,14 @@ def display_temp( raise TypeError(f"Temperature is not a number: {temperature}") if temperature_unit != ha_unit: - temperature = TemperatureConverter.convert( - temperature, temperature_unit, ha_unit + temperature = TemperatureConverter.converter_factory(temperature_unit, ha_unit)( + temperature ) # Round in the units appropriate if precision == PRECISION_HALVES: - temperature = round(temperature * 2) / 2.0 - elif precision == PRECISION_TENTHS: - temperature = round(temperature, 1) + return round(temperature * 2) / 2.0 + if precision == PRECISION_TENTHS: + return round(temperature, 1) # Integer as a fall back (PRECISION_WHOLE) - else: - temperature = round(temperature) - - return temperature + return round(temperature) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f96b2c53b50..8d837bc9bc6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Generator, Iterable from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -97,7 +97,12 @@ _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 _IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") -_RESERVED_NAMES = {"contextfunction", "evalcontextfunction", "environmentfunction"} +_RESERVED_NAMES = { + "contextfunction", + "evalcontextfunction", + "environmentfunction", + "jinja_pass_arg", +} _GROUP_DOMAIN_PREFIX = "group." _ZONE_DOMAIN_PREFIX = "zone." @@ -651,7 +656,7 @@ class Template: 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) @@ -722,6 +727,7 @@ class Template: value: Any, error_value: Any = _SENTINEL, variables: dict[str, Any] | None = None, + parse_result: bool = False, ) -> Any: """Render template with value exposed. @@ -743,7 +749,9 @@ class Template: variables["value_json"] = json_loads(value) try: - return _render_with_context(self.template, compiled, **variables).strip() + render_result = _render_with_context( + self.template, compiled, **variables + ).strip() except jinja2.TemplateError as ex: if error_value is _SENTINEL: _LOGGER.error( @@ -754,6 +762,11 @@ class Template: ) return value if error_value is _SENTINEL else error_value + if not parse_result or self.hass and self.hass.config.legacy_templates: + return render_result + + return self._parse_result(render_result) + def _ensure_compiled( self, limited: bool = False, @@ -940,7 +953,6 @@ class TemplateStateBase(State): self._hass = hass self._collect = collect self._entity_id = entity_id - self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: if self._collect and (render_info := _render_info.get()): @@ -1824,14 +1836,24 @@ def forgiving_as_timestamp(value, default=_SENTINEL): return default -def as_datetime(value): +def as_datetime(value: Any, default: Any = _SENTINEL) -> Any: """Filter and to convert a time string or UNIX timestamp to datetime object.""" try: # Check for a valid UNIX timestamp string, int or float timestamp = float(value) return dt_util.utc_from_timestamp(timestamp) - except ValueError: - return dt_util.parse_datetime(value) + except (ValueError, TypeError): + # Try to parse datetime string to datetime object + try: + return dt_util.parse_datetime(value, raise_on_error=True) + except (ValueError, TypeError): + if default is _SENTINEL: + # Return None on string input + # to ensure backwards compatibility with HA Core 2024.1 and before. + if isinstance(value, str): + return None + raise_no_default("as_datetime", value) + return default def as_timedelta(value: str) -> timedelta | None: @@ -2100,6 +2122,11 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def bitwise_xor(first_value, second_value): + """Perform a bitwise xor operation.""" + return first_value ^ second_value + + def struct_pack(value: Any | None, format_string: str) -> bytes | None: """Pack an object into a bytes object.""" try: @@ -2463,6 +2490,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or + self.filters["bitwise_xor"] = bitwise_xor self.filters["pack"] = struct_pack self.filters["unpack"] = struct_unpack self.filters["ord"] = ord @@ -2474,8 +2502,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): 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 @@ -2511,8 +2537,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): 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 6c7d6cf0a7a..21154914f17 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.""" @@ -90,7 +93,7 @@ class TraceElement: if self._variables: result["changed_variables"] = self._variables if self._error is not None: - result["error"] = str(self._error) + result["error"] = str(self._error) or self._error.__class__.__name__ if self._result is not None: result["result"] = self._result return result @@ -131,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 @@ -204,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: @@ -244,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. @@ -256,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 eac5cdb0a3f..ab9d5f576fe 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping import logging +import string from typing import Any from homeassistant.core import HomeAssistant, callback @@ -17,7 +18,6 @@ from homeassistant.util.json import load_json _LOGGER = logging.getLogger(__name__) -TRANSLATION_LOAD_LOCK = "translation_load_lock" TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" @@ -128,7 +128,7 @@ def _merge_resources( return resources -def _build_resources( +def build_resources( translation_strings: dict[str, dict[str, Any]], components: set[str], category: str, @@ -190,29 +190,45 @@ async def _async_get_component_strings( class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "loaded", "cache") + __slots__ = ("hass", "loaded", "cache", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass self.loaded: dict[str, set[str]] = {} - self.cache: dict[str, dict[str, dict[str, Any]]] = {} + self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {} + self.lock = asyncio.Lock() async def async_fetch( self, language: str, category: str, components: set[str], - ) -> list[dict[str, dict[str, Any]]]: + ) -> dict[str, str]: """Load resources into the cache.""" - components_to_load = components - self.loaded.setdefault(language, set()) + loaded = self.loaded.setdefault(language, set()) + if components_to_load := components - loaded: + # Translations are never unloaded so if there are no components to load + # we can skip the lock which reduces contention when multiple different + # translations categories are being fetched at the same time which is + # common from the frontend. + async with self.lock: + # Check components to load again, as another task might have loaded + # them while we were waiting for the lock. + if components_to_load := components - loaded: + await self._async_load(language, components_to_load) - if components_to_load: - await self._async_load(language, components_to_load) + category_cache = self.cache.get(language, {}).get(category, {}) + # If only one component was requested, return it directly + # to avoid merging the dictionaries and keeping additional + # copies of the same data in memory. + if len(components) == 1 and (component := next(iter(components))): + return category_cache.get(component, {}) - cached = self.cache.get(language, {}) - - return [cached.get(component, {}).get(category, {}) for component in components] + result: dict[str, str] = {} + for component in components.intersection(category_cache): + result.update(category_cache[component]) + return result async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" @@ -242,6 +258,42 @@ class _TranslationCache: 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, @@ -264,24 +316,26 @@ class _TranslationCache: translation_strings, components, category ) else: - new_resources = _build_resources( + new_resources = build_resources( translation_strings, components, category ) + category_cache = cached.setdefault(category, {}) + for component, resource in new_resources.items(): - category_cache: dict[str, Any] = cached.setdefault( - component, {} - ).setdefault(category, {}) + component_cache = category_cache.setdefault(component, {}) 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, component_cache + ) + component_cache.update(resources_flatten) else: - category_cache[f"component.{component}.{category}"] = resource + component_cache[f"component.{component}.{category}"] = resource @bind_hass @@ -291,35 +345,28 @@ async def async_get_translations( category: str, integrations: Iterable[str] | None = None, config_flow: bool | None = None, -) -> dict[str, Any]: +) -> dict[str, str]: """Return all backend translations. If integration specified, load it for that one. - Otherwise default to loaded intgrations combined with config flow + Otherwise default to loaded integrations combined with config flow integrations if config_flow is true. """ - lock = hass.data.setdefault(TRANSLATION_LOAD_LOCK, asyncio.Lock()) - if integrations is not None: components = set(integrations) elif config_flow: components = (await async_get_config_flows(hass)) - hass.config.components elif category in ("state", "entity_component", "services"): - components = set(hass.config.components) + components = hass.config.components else: # Only 'state' supports merging, so remove platforms from selection components = { component for component in hass.config.components if "." not in component } - async with lock: - if TRANSLATION_FLATTEN_CACHE in hass.data: - cache = hass.data[TRANSLATION_FLATTEN_CACHE] - else: - cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass) - cached = await cache.async_fetch(language, category, components) + if TRANSLATION_FLATTEN_CACHE in hass.data: + cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE] + else: + cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass) - result = {} - for entry in cached: - result.update(entry) - return result + return await cache.async_fetch(language, category, components) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a4391061899..c9ca76cdf72 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -73,7 +73,7 @@ class TriggerActionType(Protocol): self, run_variables: dict[str, Any], context: Context | None = None, - ) -> None: + ) -> Any: """Define action callback type.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 606b90e6005..d8631398db7 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,6 +84,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.logger = logger self.name = name self.update_method = update_method + self._update_interval_seconds: float | None = None self.update_interval = update_interval self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() @@ -212,10 +213,21 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._unsub_shutdown() self._unsub_shutdown = None + @property + def update_interval(self) -> timedelta | None: + """Interval between updates.""" + return self._update_interval + + @update_interval.setter + def update_interval(self, value: timedelta | None) -> None: + """Set interval between updates.""" + self._update_interval = value + self._update_interval_seconds = value.total_seconds() if value else None + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" - if self.update_interval is None: + if self._update_interval_seconds is None: return if self.config_entry and self.config_entry.pref_disable_polling: @@ -225,19 +237,20 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # than the debouncer cooldown, this would cause the debounce to never be called self._async_unsub_refresh() - # We use event.async_call_at because DataUpdateCoordinator does - # not need an exact update interval. - now = self.hass.loop.time() + # We use loop.call_at because DataUpdateCoordinator does + # not need an exact update interval which also avoids + # calling dt_util.utcnow() on every update. + hass = self.hass + loop = hass.loop - next_refresh = int(now) + self._microsecond - next_refresh += self.update_interval.total_seconds() - self._unsub_refresh = event.async_call_at( - self.hass, - self._job, - next_refresh, + next_refresh = ( + int(loop.time()) + self._microsecond + self._update_interval_seconds ) + self._unsub_refresh = loop.call_at( + next_refresh, hass.async_run_hass_job, self._job + ).cancel - async def _handle_refresh_interval(self, _now: datetime) -> None: + async def _handle_refresh_interval(self, _now: datetime | None = None) -> None: """Handle a refresh interval occurrence.""" self._unsub_refresh = None await self._async_refresh(log_failures=True, scheduled=True) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0a44ccb05c9..089a0b522fa 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -35,11 +35,16 @@ from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .util.json import JSON_DECODE_EXCEPTIONS, json_loads -# Typing imports that create a circular dependency if TYPE_CHECKING: + from functools import cached_property + + # The relative imports below are guarded by TYPE_CHECKING + # because they would cause a circular import otherwise. from .config_entries import ConfigEntry from .helpers import device_registry as dr from .helpers.typing import ConfigType +else: + from .backports.functools import cached_property _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -650,12 +655,12 @@ class Integration: _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) - @property + @cached_property def name(self) -> str: """Return name.""" return self.manifest["name"] - @property + @cached_property def disabled(self) -> str | None: """Return reason integration is disabled.""" return self.manifest.get("disabled") @@ -710,7 +715,7 @@ class Integration: """Return the integration IoT Class.""" return self.manifest.get("iot_class") - @property + @cached_property def integration_type( self, ) -> Literal["entity", "device", "hardware", "helper", "hub", "service", "system"]: @@ -1177,8 +1182,12 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: Async friendly but not a coroutine. """ - if hass.config.config_dir not in sys.path: - sys.path.insert(0, hass.config.config_dir) + + sys.path.insert(0, hass.config.config_dir) + with suppress(ImportError): + import custom_components # pylint: disable=import-outside-toplevel # noqa: F401 + sys.path.remove(hass.config.config_dir) + sys.path_importer_cache.pop(hass.config.config_dir, None) def _lookup_path(hass: HomeAssistant) -> list[str]: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d030118dae..e3a82474d8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,57 +2,57 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.1 aiohttp==3.9.3 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.38.1 atomicwrites-homeassistant==1.4.1 -attrs==23.1.0 -awesomeversion==23.11.0 +attrs==23.2.0 +awesomeversion==24.2.0 bcrypt==4.0.1 bleak-retry-connector==3.4.0 bleak==0.21.1 -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 bluetooth-auto-recovery==1.3.0 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.7 -dbus-fast==2.21.0 +cryptography==42.0.2 +dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.1.0 -hass-nabucasa==0.75.1 -hassil==1.5.1 +habluetooth==2.4.0 +hass-nabucasa==0.76.0 +hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240104.0 -home-assistant-intents==2024.1.2 +home-assistant-frontend==20240207.0 +home-assistant-intents==2024.2.2 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.9 +orjson==3.9.13 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.1.0 +Pillow==10.2.0 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 PyNaCl==1.5.0 -pyOpenSSL==23.2.0 +pyOpenSSL==24.0.0 pyserial==3.5 -python-slugify==4.0.1 +python-slugify==8.0.1 PyTurboJPEG==1.7.1 pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.25 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 urllib3>=1.26.5,<2 @@ -145,9 +145,9 @@ iso4217!=1.10.20220401 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.1.0 or later required to avoid import errors when -# cryptography 40.0.1 is installed with botocore -pyOpenSSL>=23.1.0 +# pyOpenSSL 24.0.0 or later required to avoid import errors when +# cryptography 42.0.0 is installed with botocore +pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels @@ -182,13 +182,12 @@ get-mac==1000000000.0.0 # 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 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. pandas==2.1.4 + +# chacha20poly1305-reuseable==0.12.0 is incompatible with cryptography==42.0.x +chacha20poly1305-reuseable>=0.12.1 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 27a9607a6ee..9892a4d9169 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -63,6 +63,13 @@ async def async_process_requirements( await _async_get_manager(hass).async_process_requirements(name, requirements) +async def async_load_installed_versions( + hass: HomeAssistant, requirements: set[str] +) -> None: + """Load the installed version of requirements.""" + await _async_get_manager(hass).async_load_installed_versions(requirements) + + @callback def _async_get_manager(hass: HomeAssistant) -> RequirementsManager: """Get the requirements manager.""" @@ -284,3 +291,15 @@ class RequirementsManager: self.install_failure_history |= failures if failures: raise RequirementsNotFound(name, list(failures)) + + async def async_load_installed_versions( + self, + requirements: set[str], + ) -> None: + """Load the installed version of requirements.""" + if not (requirements_to_check := requirements - self.is_installed_cache): + return + + self.is_installed_cache |= await self.hass.async_add_executor_job( + pkg_util.get_installed_versions, requirements_to_check + ) diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 5714e5814a4..dd3b9b7ba48 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -9,6 +9,7 @@ from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er # mypy: allow-untyped-calls, allow-untyped-defs @@ -51,6 +52,7 @@ def run(args): async def run_command(args): """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = hass.auth.auth_providers[0] await provider.async_initialize() diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index dcccdbccf40..5a9f62c938e 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -27,7 +27,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.7.0",) +REQUIREMENTS = ("colorlog==6.8.2",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 7a7f4323be6..5408da20a70 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -256,8 +256,9 @@ async def _async_setup_component( 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 + conf_util.async_handle_component_errors(hass, integration_config_info, integration) + processed_config = conf_util.async_drop_config_annotations( + 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: diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4859c5c85dd..47863d32e67 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,7 +6,7 @@ from contextlib import suppress import datetime as dt from functools import partial import re -from typing import Any +from typing import Any, Literal, overload import zoneinfo import ciso8601 @@ -177,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"]: @@ -370,7 +393,8 @@ def find_next_time_expression_time( next_second = seconds[0] result += dt.timedelta(minutes=1) - result = result.replace(second=next_second) + if result.second != next_second: + result = result.replace(second=next_second) # Match next minute next_minute = _lower_bound(minutes, result.minute) @@ -383,7 +407,8 @@ def find_next_time_expression_time( next_minute = minutes[0] result += dt.timedelta(hours=1) - result = result.replace(minute=next_minute) + if result.minute != next_minute: + result = result.replace(minute=next_minute) # Match next hour next_hour = _lower_bound(hours, result.hour) @@ -396,7 +421,8 @@ def find_next_time_expression_time( next_hour = hours[0] result += dt.timedelta(days=1) - result = result.replace(hour=next_hour) + if result.hour != next_hour: + result = result.replace(hour=next_hour) if result.tzinfo in (None, UTC): # Using UTC, no DST checking needed diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 1af35c604eb..630c39b3ad4 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -39,9 +39,10 @@ def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: This adds a workaround for orjson not handling subclasses of str, https://github.com/ijl/orjson/issues/445. """ - if type(__obj) in (bytes, bytearray, memoryview, str): - return orjson.loads(__obj) # type:ignore[no-any-return] - if isinstance(__obj, str): + # 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] @@ -65,7 +66,7 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject def load_json( - filename: str | PathLike, + filename: str | PathLike[str], default: JsonValueType = _SENTINEL, # type: ignore[assignment] ) -> JsonValueType: """Load JSON data from a file. @@ -88,7 +89,7 @@ def load_json( def load_json_array( - filename: str | PathLike, + filename: str | PathLike[str], default: JsonArrayType = _SENTINEL, # type: ignore[assignment] ) -> JsonArrayType: """Load JSON data from a file and return as list. @@ -108,7 +109,7 @@ def load_json_array( def load_json_object( - filename: str | PathLike, + filename: str | PathLike[str], 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 b2ef7330660..9e9c434822e 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -5,6 +5,7 @@ detect_location_info and elevation are mocked by default during tests. from __future__ import annotations import asyncio +from functools import lru_cache import math from typing import Any, NamedTuple @@ -57,6 +58,7 @@ async def async_detect_location_info( return LocationInfo(**data) +@lru_cache def distance( lat1: float | None, lon1: float | None, lat2: float, lon2: float ) -> float | None: diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index bc60953a1aa..ce6276ef4d4 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from functools import cache -from importlib.metadata import PackageNotFoundError, distribution, version +from importlib.metadata import PackageNotFoundError, version import logging import os from pathlib import Path @@ -30,29 +30,49 @@ def is_docker_env() -> bool: return Path("/.dockerenv").exists() -def is_installed(package: str) -> bool: +def get_installed_versions(specifiers: set[str]) -> set[str]: + """Return a set of installed packages and versions.""" + return {specifier for specifier in specifiers if is_installed(specifier)} + + +def is_installed(requirement_str: str) -> bool: """Check if a package is installed and will be loaded when we import it. + expected input is a pip compatible package specifier (requirement string) + e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" + + For backward compatibility, it also accepts a URL with a fragment + e.g. "git+https://github.com/pypa/pip#pip>=1" + Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req. """ try: - distribution(package) - return True - except (IndexError, PackageNotFoundError): + req = Requirement(requirement_str) + except InvalidRequirement: + if "#" not in requirement_str: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return False + + # This is likely a URL with a fragment + # example: git+https://github.com/pypa/pip#pip>=1 + + # fragment support was originally used to install zip files, and + # we no longer do this in Home Assistant. However, custom + # components started using it to install packages from git + # urls which would make it would be a breaking change to + # remove it. try: - req = Requirement(package) + req = Requirement(urlparse(requirement_str).fragment) except InvalidRequirement: - # This is a zip file. We no longer use this in Home Assistant, - # leaving it in for custom components. - req = Requirement(urlparse(package).fragment) + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return False try: - installed_version = version(req.name) - # This will happen when an install failed or - # was aborted while in progress see - # https://github.com/home-assistant/core/issues/47699 - if installed_version is None: + if (installed_version := version(req.name)) is None: + # This can happen when an install failed or + # was aborted while in progress see + # https://github.com/home-assistant/core/issues/47699 _LOGGER.error( # type: ignore[unreachable] "Installed version for %s resolved to None", req.name ) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5ce31b072cf..be356a8ad5f 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -20,7 +20,9 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -38,7 +40,9 @@ _MILE_TO_M = _YARD_TO_M * 1760 # 1760 yard = 1 mile (1609.344 m) _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Duration conversion constants -_HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +_MIN_TO_SEC = 60 # 1 min = 60 seconds +_HRS_TO_MINUTES = 60 # 1 hr = 60 minutes +_HRS_TO_SECS = _HRS_TO_MINUTES * _MIN_TO_SEC # 1 hr = 60 minutes = 3600 seconds _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds # Mass conversion constants @@ -516,3 +520,51 @@ class VolumeConverter(BaseUnitConverter): UnitOfVolume.CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET, } + + +class VolumeFlowRateConverter(BaseUnitConverter): + """Utility to convert volume values.""" + + UNIT_CLASS = "volume_flow_rate" + NORMALIZED_UNIT = UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + # Units in terms of m³/h + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _CUBIC_FOOT_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), + } + VALID_UNITS = { + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + } + + +class DurationConverter(BaseUnitConverter): + """Utility to convert duration values.""" + + UNIT_CLASS = "duration" + NORMALIZED_UNIT = UnitOfTime.SECONDS + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfTime.MICROSECONDS: 1000000, + UnitOfTime.MILLISECONDS: 1000, + UnitOfTime.SECONDS: 1, + UnitOfTime.MINUTES: 1 / _MIN_TO_SEC, + UnitOfTime.HOURS: 1 / _HRS_TO_SECS, + UnitOfTime.DAYS: 1 / _DAYS_TO_SECS, + UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS), + } + VALID_UNITS = { + UnitOfTime.MICROSECONDS, + UnitOfTime.MILLISECONDS, + UnitOfTime.SECONDS, + UnitOfTime.MINUTES, + UnitOfTime.HOURS, + UnitOfTime.DAYS, + UnitOfTime.WEEKS, + } diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 65747d1fd3e..ec4700ef17e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -17,7 +17,7 @@ except ImportError: ) -def dump(_dict: dict) -> str: +def dump(_dict: dict | list) -> str: """Dump YAML to a string and remove null.""" return yaml.dump( _dict, diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 5d66ea23dcb..51564b6da88 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -215,7 +215,9 @@ class SafeLineLoader(PythonSafeLoader): LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: +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: @@ -225,7 +227,9 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: raise HomeAssistantError(exc) from exc -def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: +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. diff --git a/mypy.ini b/mypy.ini index e4546526722..6bafe51e1a0 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 @@ -250,6 +260,26 @@ 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 @@ -260,6 +290,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airtouch5.*] +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 @@ -340,6 +380,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 @@ -350,6 +400,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 @@ -390,6 +460,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.analytics_insights.*] +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.android_ip_webcam.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -400,6 +480,16 @@ 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 @@ -410,6 +500,16 @@ 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 @@ -430,6 +530,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 @@ -440,6 +550,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.api.*] +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.apprise.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -450,6 +570,16 @@ 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 @@ -460,6 +590,16 @@ 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 @@ -470,6 +610,46 @@ 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 @@ -490,6 +670,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 @@ -530,6 +730,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 @@ -550,6 +760,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bang_olufsen.*] +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.bayesian.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -600,6 +820,16 @@ 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 @@ -610,6 +840,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bluetooth_adapters.*] +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_tracker.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -670,6 +910,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bthome.*] +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.button.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -710,6 +960,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cert_expiry.*] +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.clickatell.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -750,6 +1010,36 @@ 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.config.*] +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 @@ -760,6 +1050,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 @@ -790,6 +1090,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 @@ -800,6 +1120,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.default_config.*] +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.demo.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -901,6 +1231,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dlna_dms.*] +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.dnsip.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -931,6 +1271,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.downloader.*] +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.dsmr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -941,6 +1291,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.duckdns.*] +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.dunehd.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -951,6 +1311,46 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.duotecno.*] +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.easyenergy.*] +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.ecovacs.*] +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.ecowitt.*] +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.efergy.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1021,6 +1421,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.energyzero.*] +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.enigma2.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1031,6 +1441,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.enphase_envoy.*] +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 @@ -1241,6 +1661,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.generic_hygrostat.*] +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.generic_thermostat.*] +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.geo_location.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1301,6 +1741,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_assistant_sdk.*] +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.google_sheets.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1391,6 +1841,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.history_stats.*] +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.holiday.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1401,17 +1861,7 @@ 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 -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.triggers.event] +[mypy-homeassistant.components.homeassistant.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true @@ -1601,6 +2051,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 @@ -1721,6 +2181,36 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.intent.*] +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.intent_script.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ios.*] +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.ipp.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1841,6 +2331,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lamarzocco.*] +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.lametric.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1891,6 +2391,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_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.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2041,6 +2551,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.map.*] +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.mastodon.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2101,6 +2621,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.met_eireann.*] +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.metoffice.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2131,6 +2661,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.minecraft_server.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.mjpeg.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2201,6 +2741,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.my.*] +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.mysensors.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2211,6 +2761,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.myuplink.*] +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.nam.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2291,6 +2851,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nightscout.*] +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.nissan_leaf.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2351,6 +2921,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onboarding.*] +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.oncue.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2411,6 +2991,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.oralb.*] +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.otbr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2431,6 +3021,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.p1_monitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.peco.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2511,6 +3111,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.prometheus.*] +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.proximity.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2581,6 +3191,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rabbitair.*] +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.radarr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2591,6 +3211,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rainforest_raven.*] +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.rainmachine.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2681,6 +3311,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rest_command.*] +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.rfxtrx.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2731,6 +3371,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.romy.*] +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.rpi_power.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2821,6 +3471,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.search.*] +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.select.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2891,6 +3551,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.shopping_list.*] +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.simplepush.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2911,6 +3581,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 @@ -3062,6 +3742,16 @@ 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 @@ -3142,6 +3832,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.system_health.*] +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.system_log.*] +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.systemmonitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3212,6 +3922,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.technove.*] +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.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 @@ -3262,6 +3992,56 @@ 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.timer.*] +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.tod.*] +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 @@ -3292,6 +4072,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trace.*] +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.tractive.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3523,6 +4313,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 @@ -3533,6 +4333,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.waqi.*] +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.water_heater.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3563,6 +4373,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.webhook.*] +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.webostv.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3643,6 +4463,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.xiaomi_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.yale_smart_alarm.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3663,6 +4493,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.youtube.*] +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.zeroconf.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_coordinator_module.py b/pylint/plugins/hass_enforce_coordinator_module.py new file mode 100644 index 00000000000..3546632547b --- /dev/null +++ b/pylint/plugins/hass_enforce_coordinator_module.py @@ -0,0 +1,54 @@ +"""Plugin for checking if coordinator is in its own module.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassEnforceCoordinatorModule(BaseChecker): + """Checker for coordinators own module.""" + + name = "hass_enforce_coordinator_module" + priority = -1 + msgs = { + "C7461": ( + "Derived data update coordinator is recommended to be placed in the 'coordinator' module", + "hass-enforce-coordinator-module", + "Used when derived data update coordinator should be placed in its own module.", + ), + } + options = ( + ( + "ignore-wrong-coordinator-module", + { + "default": False, + "type": "yn", + "metavar": "", + "help": "Set to ``no`` if you wish to check if derived data update coordinator " + "is placed in its own module.", + }, + ), + ) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Check if derived data update coordinator is placed in its own module.""" + if self.linter.config.ignore_wrong_coordinator_module: + return + + root_name = node.root().name + + # we only want to check component update coordinators + if not root_name.startswith("homeassistant.components"): + return + + is_coordinator_module = root_name.endswith(".coordinator") + for ancestor in node.ancestors(): + if ancestor.name == "DataUpdateCoordinator" and not is_coordinator_module: + self.add_message("hass-enforce-coordinator-module", node=node) + return + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceCoordinatorModule(linter)) diff --git a/pylint/plugins/hass_enforce_sorted_platforms.py b/pylint/plugins/hass_enforce_sorted_platforms.py new file mode 100644 index 00000000000..b3fb7c8adcc --- /dev/null +++ b/pylint/plugins/hass_enforce_sorted_platforms.py @@ -0,0 +1,39 @@ +"""Plugin for checking sorted platforms list.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassEnforceSortedPlatformsChecker(BaseChecker): + """Checker for sorted platforms list.""" + + name = "hass_enforce_sorted_platforms" + priority = -1 + msgs = { + "W7451": ( + "Platforms must be sorted alphabetically", + "hass-enforce-sorted-platforms", + "Used when PLATFORMS should be sorted alphabetically.", + ), + } + options = () + + def visit_assign(self, node: nodes.Assign) -> None: + """Check for sorted PLATFORMS const.""" + for target in node.targets: + if ( + isinstance(target, nodes.AssignName) + and target.name == "PLATFORMS" + and isinstance(node.value, nodes.List) + ): + platforms = [v.as_string() for v in node.value.elts] + sorted_platforms = sorted(platforms) + if platforms != sorted_platforms: + self.add_message("hass-enforce-sorted-platforms", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSortedPlatformsChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index f6d936fb637..6a038aa1c5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.6" +version = "2024.2.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -26,11 +26,11 @@ dependencies = [ "aiohttp==3.9.3", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3", + "aiohttp-zlib-ng==0.3.1", "astral==2.2", - "attrs==23.1.0", + "attrs==23.2.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==23.11.0", + "awesomeversion==24.2.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", @@ -43,13 +43,13 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.7", + "cryptography==42.0.2", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ - "pyOpenSSL==23.2.0", - "orjson==3.9.9", + "pyOpenSSL==24.0.0", + "orjson==3.9.13", "packaging>=23.1", "pip>=21.3.1", - "python-slugify==4.0.1", + "python-slugify==8.0.1", "PyYAML==6.0.1", "requests==2.31.0", "typing-extensions>=4.9.0,<5.0", @@ -103,6 +103,8 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_coordinator_module", + "hass_enforce_sorted_platforms", "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", diff --git a/requirements.txt b/requirements.txt index eaf5b8a9e22..63ea582eba8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,11 @@ aiohttp==3.9.3 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.1 astral==2.2 -attrs==23.1.0 +attrs==23.2.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==23.11.0 +awesomeversion==24.2.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 @@ -20,12 +20,12 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 PyJWT==2.8.0 -cryptography==41.0.7 -pyOpenSSL==23.2.0 -orjson==3.9.9 +cryptography==42.0.2 +pyOpenSSL==24.0.0 +orjson==3.9.13 packaging>=23.1 pip>=21.3.1 -python-slugify==4.0.1 +python-slugify==8.0.1 PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.9.0,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 22d8e110fe7..a61d360c4e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.7 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.24 +AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 @@ -42,7 +42,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.1.0 +Pillow==10.2.0 # homeassistant.components.plex PlexAPI==4.15.7 @@ -54,7 +54,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.0.8 +PyChromecast==13.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -76,7 +76,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.9 +PyMicroBot==0.0.10 # homeassistant.components.nina PyNINA==0.3.3 @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.43.0 +PySwitchbot==0.44.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -118,7 +118,7 @@ PyViCare==2.32.0 PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 +RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==7.0 @@ -128,7 +128,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.25 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 @@ -170,31 +170,31 @@ agent-py==0.0.23 aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes -aio-geojson-geonetnz-quakes==0.15 +aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio-geojson-nsw-rfs-incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.7 # homeassistant.components.usgs_earthquakes_feed aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio-georss-gdacs==0.8 +aio-georss-gdacs==0.9 # homeassistant.components.airq aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone aioairzone==0.7.2 # homeassistant.components.ambient_station -aioambient==2023.04.0 +aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 @@ -212,10 +212,10 @@ aioazuredevops==1.3.5 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.6.0 +aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.7.3 +aiocomelit==0.8.3 # homeassistant.components.dhcp aiodiscover==1.6.0 @@ -230,16 +230,16 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.5.0 +aioecowitt==2024.2.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.5 +aioelectricitymaps==0.3.1 # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.1 +aioesphomeapi==21.0.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -257,13 +257,13 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.3 +aiohomekit==3.1.4 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.1 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -276,7 +276,7 @@ aiohue==4.7.0 aioimaplib==1.0.1 # homeassistant.components.apache_kafka -aiokafka==0.7.2 +aiokafka==0.10.0 # homeassistant.components.kef aiokef==0.2.16 @@ -340,6 +340,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -356,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.1.0 +aioshelly==8.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -373,11 +376,14 @@ aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tankerkoenig +aiotankerkoenig==0.3.0 + # homeassistant.components.tractive aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==69 +aiounifi==70 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -395,7 +401,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==2.0.0 +aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -404,19 +410,22 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.0 +airthings-ble==0.6.1 # homeassistant.components.airthings -airthings-cloud==0.1.0 +airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 +# homeassistant.components.airtouch5 +airtouch5py==0.2.8 + # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 # homeassistant.components.amberelectric -amberelectric==1.0.4 +amberelectric==1.1.0 # homeassistant.components.amcrest amcrest==1.9.8 @@ -440,7 +449,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.6.0 +apprise==1.7.2 # homeassistant.components.aprs aprslib==0.7.0 @@ -478,7 +487,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.4.1 +asyncsleepiq==1.5.2 # homeassistant.components.aten_pe # atenpdu==0.3.2 @@ -526,7 +535,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.6 +bellows==0.38.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -535,7 +544,7 @@ bimmer-connected[china]==0.14.6 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==0.4.0 +bleak-esphome==0.4.1 # homeassistant.components.bluetooth bleak-retry-connector==3.4.0 @@ -547,7 +556,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.5 +blinkpy==0.22.6 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -563,7 +572,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.3.0 @@ -582,7 +591,7 @@ boschshcpy==0.2.75 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.28.17 +boto3==1.33.13 # homeassistant.components.broadlink broadlink==0.18.3 @@ -600,7 +609,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.3.1 +bthome-ble==3.5.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -633,7 +642,7 @@ clx-sdk-xms==1.0.0 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.7.0 +colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -662,11 +671,8 @@ crownstone-uart==2.1.0 # homeassistant.components.datadog datadog==0.15.0 -# homeassistant.components.metoffice -datapoint==0.9.8;python_version<'3.12' - # homeassistant.components.bluetooth -dbus-fast==2.21.0 +dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 @@ -677,6 +683,9 @@ debugpy==1.8.0 # homeassistant.components.decora # decora==0.6 +# homeassistant.components.ecovacs +deebot-client==5.1.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect @@ -722,7 +731,7 @@ dropmqttapi==1.0.2 dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.6 +dwdwfsapi==1.0.7 # homeassistant.components.dweet dweepy==0.3.0 @@ -737,7 +746,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.1.0 +easyenergy==2.1.1 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -749,7 +758,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.1 +elgato==5.1.2 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -760,6 +769,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.elvia +elvia==0.1.0 + # homeassistant.components.xmpp emoji==2.8.0 @@ -784,6 +796,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 @@ -803,7 +818,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.15 +evohome-async==0.4.17 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -890,7 +905,7 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss-generic-client==0.6 +georss-generic-client==0.8 # homeassistant.components.ign_sismologia georss-ign-sismologia-client==0.6 @@ -946,7 +961,10 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.24.0 +govee-ble==0.31.0 + +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -998,16 +1016,16 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.1.0 +habluetooth==2.4.0 # homeassistant.components.cloud -hass-nabucasa==0.75.1 +hass-nabucasa==0.76.0 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.6.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -1027,6 +1045,9 @@ hikvision==0.4 # homeassistant.components.harman_kardon_avr hkavr==0.0.5 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 @@ -1035,13 +1056,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.39 +holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240104.0 +home-assistant-frontend==20240207.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.2 +home-assistant-intents==2024.2.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1049,9 +1070,6 @@ homeconnect==0.7.2 # homeassistant.components.homematicip_cloud homematicip==1.0.16 -# homeassistant.components.home_plus_control -homepluscontrol==0.0.5 - # homeassistant.components.horizon horimote==0.4.1 @@ -1061,6 +1079,9 @@ httplib2==0.20.4 # homeassistant.components.huawei_lte huawei-lte-api==1.7.3 +# homeassistant.components.huum +huum==0.7.10 + # homeassistant.components.hyperion hyperion-py==0.7.5 @@ -1085,7 +1106,7 @@ ical==6.1.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.4 +idasen-ha==2.5 # homeassistant.components.network ifaddr==0.2.0 @@ -1136,7 +1157,7 @@ jellyfin-apiclient-python==1.9.2 jsonpath==0.82.2 # homeassistant.components.justnimbus -justnimbus==0.6.0 +justnimbus==0.7.3 # homeassistant.components.kaiterra kaiterra-async-client==1.0.0 @@ -1151,7 +1172,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx-frontend==2023.6.23.191712 +knx-frontend==2024.1.20.105944 # homeassistant.components.konnected konnected==1.2.0 @@ -1171,6 +1192,9 @@ laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 +# homeassistant.components.leaone +leaone-ble==0.1.0 + # homeassistant.components.led_ble led-ble==1.0.1 @@ -1186,9 +1210,6 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.life360 -life360==6.0.1 - # homeassistant.components.osramlightify lightify==1.0.7.3 @@ -1204,6 +1225,9 @@ linear-garage-door==0.2.7 # homeassistant.components.linode linode-api==4.1.9b1 +# homeassistant.components.lamarzocco +lmcloud==0.4.35 + # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -1220,19 +1244,19 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.3.1 +lupupy==0.3.2 # homeassistant.components.lw12wifi lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.4 +lxml==5.1.0 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.22.1 +matrix-nio==0.24.0 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -1286,11 +1310,14 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.19 +motionblinds==0.6.20 # homeassistant.components.motioneye motioneye-client==0.3.14 +# homeassistant.components.bang_olufsen +mozart-api==3.2.1.150.6 + # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1301,7 +1328,10 @@ mutagen==1.47.0 mutesync==0.0.1 # homeassistant.components.permobil -mypermobil==0.1.6 +mypermobil==0.1.8 + +# homeassistant.components.myuplink +myuplink==0.0.9 # homeassistant.components.nad nad-receiver==0.3.0 @@ -1325,10 +1355,10 @@ nettigo-air-monitor==2.2.2 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.7 +nexia==2.0.8 # homeassistant.components.nextcloud -nextcloudmonitor==1.4.0 +nextcloudmonitor==1.5.0 # homeassistant.components.discord nextcord==2.0.0a8 @@ -1337,7 +1367,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.5.2 +nibe==2.8.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1435,7 +1465,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.1.0 +opower==0.2.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1500,7 +1530,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.35.3 +plugwise==0.36.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1531,7 +1561,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.7 +psutil==5.9.8 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1549,7 +1579,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.4 +py-aosmith==1.0.6 # homeassistant.components.canary py-canary==0.5.3 @@ -1600,7 +1630,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.1.1 +pyDuotecno==2024.1.2 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1649,7 +1679,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1724,7 +1754,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 @@ -1745,7 +1775,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.17.0 +pyenphase==1.19.0 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1784,7 +1814,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.9 +pyfritzhome==0.6.10 # homeassistant.components.ifttt pyfttt==0.3 @@ -1820,7 +1850,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.2 +pyinsteon==1.5.3 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -1847,7 +1877,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.6 +pyjvcprojector==1.0.9 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1895,7 +1925,7 @@ pylitejet==0.6.2 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.3 +pylutron-caseta==0.19.0 # homeassistant.components.lutron pylutron==0.2.8 @@ -1910,10 +1940,10 @@ pymata-express==1.19 pymediaroom==0.6.5.4 # homeassistant.components.melcloud -pymelcloud==2.5.8 +pymelcloud==2.5.9 # homeassistant.components.meteoclimatic -pymeteoclimatic==0.0.6 +pymeteoclimatic==0.1.0 # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -1922,7 +1952,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.4 +pymodbus==3.6.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2129,12 +2159,12 @@ pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.tankerkoenig -pytankerkoenig==0.0.6 - # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.13 + # homeassistant.components.tfiac pytfiac==0.4 @@ -2150,8 +2180,11 @@ python-awair==0.2.4 # homeassistant.components.blockchain python-blockchain-api==0.0.2 +# homeassistant.components.bring +python-bring-api==3.0.0 + # homeassistant.components.bsblan -python-bsblan==0.5.16 +python-bsblan==0.5.18 # homeassistant.components.clementine python-clementine-remote==1.0.1 @@ -2180,8 +2213,11 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.analytics_insights +python-homeassistant-analytics==0.6.0 + # homeassistant.components.homewizard -python-homewizard-energy==4.1.0 +python-homewizard-energy==4.3.0 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2196,19 +2232,19 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.2.1 # homeassistant.components.lirc # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.1.1 +python-matter-server==5.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 # homeassistant.components.mpd -python-mpd2==3.0.5 +python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -2221,7 +2257,7 @@ python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.5.0 +python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2229,21 +2265,27 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.ripple python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.38.0 +python-roborock==0.39.1 # homeassistant.components.smarttub python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16 +python-songpal==0.16.1 # homeassistant.components.tado python-tado==0.17.4 +# homeassistant.components.technove +python-technove==1.2.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -2263,6 +2305,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.traccar +# homeassistant.components.traccar_server pytraccar==2.0.0 # homeassistant.components.tradfri @@ -2272,7 +2315,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.2 +pytrafikverket==0.3.10 # homeassistant.components.v2c pytrydan==0.4.0 @@ -2317,7 +2360,7 @@ pyweatherflowudp==1.4.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.3.0 +pywemo==1.4.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -2368,7 +2411,7 @@ raspyrfm-client==1.2.8 refoss-ha==1.2.0 # homeassistant.components.rainmachine -regenmaschine==2023.06.0 +regenmaschine==2024.01.0 # homeassistant.components.renault renault-api==0.2.1 @@ -2386,7 +2429,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.5 +ring-doorbell[listen]==0.8.7 # homeassistant.components.fleetgo ritassist==0.9.2 @@ -2398,7 +2441,10 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.18.1 +rokuecp==0.19.0 + +# homeassistant.components.romy +romy==0.0.7 # homeassistant.components.roomba roombapy==1.6.10 @@ -2462,10 +2508,10 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.2 # homeassistant.components.sentry -sentry-sdk==1.37.1 +sentry-sdk==1.39.2 # homeassistant.components.sfr_box sfrbox-api==0.0.8 @@ -2486,7 +2532,7 @@ simplehound==0.3 simplepush==2.2.3 # homeassistant.components.simplisafe -simplisafe-python==2023.08.0 +simplisafe-python==2024.01.0 # homeassistant.components.sisyphus sisyphus-control==3.1.3 @@ -2507,7 +2553,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.30.0 +soco==0.30.2 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2522,7 +2568,7 @@ solax==0.3.2 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.2 +sonos-websocket==0.1.3 # homeassistant.components.marytts speak2mary==1.4.0 @@ -2579,7 +2625,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.3 +sunweg==2.1.0 # homeassistant.components.surepetcare surepy==0.9.0 @@ -2588,7 +2634,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.3.0 +switchbot-api==2.0.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2623,8 +2669,11 @@ temperusb==1.6.1 # homeassistant.components.tensorflow # tensorflow==2.5.0 +# homeassistant.components.teslemetry +tesla-fleet-api==0.2.3 + # homeassistant.components.powerwall -tesla-powerwall==0.3.19 +tesla-powerwall==0.5.1 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 @@ -2639,7 +2688,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.5.0 +thermopro-ble==0.9.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 @@ -2681,7 +2730,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 @@ -2708,7 +2757,7 @@ unifi_ap==0.0.1 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.15 +universal-silabs-flasher==0.0.18 # homeassistant.components.upb upb-lib==0.5.4 @@ -2728,13 +2777,13 @@ uvcclient==0.11.0 vacuum-map-parser-roborock==0.1.1 # homeassistant.components.vallox -vallox-websocket-api==4.0.2 +vallox-websocket-api==4.0.3 # homeassistant.components.rdw vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.11.0 +velbus-aio==2023.12.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2758,7 +2807,7 @@ vsure==2.6.6 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.0 +vulcan-api==2.3.2 # homeassistant.components.vultr vultr==0.1.2 @@ -2776,9 +2825,6 @@ watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 -# homeassistant.components.cisco_webex_teams -webexteamssdk==1.1.1;python_version<'3.12' - # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 @@ -2801,19 +2847,19 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.4.0 +wyoming==1.5.2 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.1 +xiaomi-ble==0.23.1 # homeassistant.components.knx -xknx==2.11.2 +xknx==2.12.0 # homeassistant.components.knx -xknxproject==3.4.0 +xknxproject==3.5.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2831,7 +2877,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.4.0 +yalexs-ble==2.4.1 # homeassistant.components.august yalexs==1.10.0 @@ -2855,7 +2901,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.3 +zamg==0.3.5 # homeassistant.components.zengge zengge==0.2 @@ -2867,7 +2913,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.109 +zha-quirks==0.0.111 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2876,7 +2922,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.4 +zigpy-deconz==0.23.0 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2888,7 +2934,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.7 +zigpy==0.62.3 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test.txt b/requirements_test.txt index 3a552741812..1f9dda7cc44 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.4 +coverage==7.4.1 freezegun==1.3.1 mock-open==1.4.0 mypy==1.8.0 @@ -16,8 +16,8 @@ pre-commit==3.6.0 pydantic==1.10.12 pylint==3.0.3 pylint-per-file-ignores==1.2.1 -pipdeptree==2.11.0 -pytest-asyncio==0.21.0 +pipdeptree==2.13.2 +pytest-asyncio==0.23.4 pytest-aiohttp==1.0.5 pytest-cov==4.1.0 pytest-freezer==0.4.8 @@ -28,24 +28,24 @@ 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 tqdm==4.66.1 -types-aiofiles==23.2.0.0 +types-aiofiles==23.2.0.20240106 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 -types-beautifulsoup4==4.12.0.6 -types-caldav==1.3.0.0 +types-beautifulsoup4==4.12.0.20240106 +types-caldav==1.3.0.20240106 types-chardet==0.1.5 -types-decorator==5.1.8.4 -types-paho-mqtt==1.6.0.7 -types-Pillow==10.0.0.3 -types-protobuf==4.24.0.2 -types-psutil==5.9.5.16 -types-python-dateutil==2.8.19.14 -types-python-slugify==0.1.2 +types-decorator==5.1.8.20240106 +types-paho-mqtt==1.6.0.20240106 +types-pillow==10.2.0.20240111 +types-protobuf==4.24.0.20240106 +types-psutil==5.9.5.20240106 +types-python-dateutil==2.8.19.20240106 +types-python-slugify==8.0.0.3 types-pytz==2023.3.1.1 types-PyYAML==6.0.12.12 types-requests==2.31.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb89514bf1d..67c0775c2f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.7 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.24 +AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 @@ -36,7 +36,7 @@ HATasmota==0.8.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.1.0 +Pillow==10.2.0 # homeassistant.components.plex PlexAPI==4.15.7 @@ -45,7 +45,7 @@ PlexAPI==4.15.7 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.0.8 +PyChromecast==13.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -64,7 +64,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.9 +PyMicroBot==0.0.10 # homeassistant.components.nina PyNINA==0.3.3 @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.43.0 +PySwitchbot==0.44.0 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -103,7 +103,7 @@ PyViCare==2.32.0 PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 +RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==7.0 @@ -113,7 +113,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.25 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 @@ -149,31 +149,31 @@ agent-py==0.0.23 aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes -aio-geojson-geonetnz-quakes==0.15 +aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio-geojson-nsw-rfs-incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.7 # homeassistant.components.usgs_earthquakes_feed aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio-georss-gdacs==0.8 +aio-georss-gdacs==0.9 # homeassistant.components.airq aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone aioairzone==0.7.2 # homeassistant.components.ambient_station -aioambient==2023.04.0 +aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 @@ -191,10 +191,10 @@ aioazuredevops==1.3.5 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.6.0 +aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.7.3 +aiocomelit==0.8.3 # homeassistant.components.dhcp aiodiscover==1.6.0 @@ -209,16 +209,16 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.5.0 +aioecowitt==2024.2.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.5 +aioelectricitymaps==0.3.1 # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.1 +aioesphomeapi==21.0.2 # 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.1.3 +aiohomekit==3.1.4 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.1 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -252,7 +252,7 @@ aiohue==4.7.0 aioimaplib==1.0.1 # homeassistant.components.apache_kafka -aiokafka==0.7.2 +aiokafka==0.10.0 # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -313,6 +313,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -329,7 +332,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.1.0 +aioshelly==8.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -346,11 +349,14 @@ aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tankerkoenig +aiotankerkoenig==0.3.0 + # homeassistant.components.tractive aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==69 +aiounifi==70 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -368,7 +374,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==2.0.0 +aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -377,16 +383,19 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.0 +airthings-ble==0.6.1 # homeassistant.components.airthings -airthings-cloud==0.1.0 +airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 +# homeassistant.components.airtouch5 +airtouch5py==0.2.8 + # homeassistant.components.amberelectric -amberelectric==1.0.4 +amberelectric==1.1.0 # homeassistant.components.androidtv androidtv[async]==0.0.73 @@ -404,7 +413,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.6.0 +apprise==1.7.2 # homeassistant.components.aprs aprslib==0.7.0 @@ -424,7 +433,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.38.1 # homeassistant.components.sleepiq -asyncsleepiq==1.4.1 +asyncsleepiq==1.5.2 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -448,13 +457,13 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.6 +bellows==0.38.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 # homeassistant.components.esphome -bleak-esphome==0.4.0 +bleak-esphome==0.4.1 # homeassistant.components.bluetooth bleak-retry-connector==3.4.0 @@ -466,7 +475,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.5 +blinkpy==0.22.6 # homeassistant.components.blue_current bluecurrent-api==1.0.6 @@ -475,7 +484,7 @@ bluecurrent-api==1.0.6 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.3.0 @@ -505,7 +514,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.3.1 +bthome-ble==3.5.0 # homeassistant.components.buienradar buienradar==1.0.5 @@ -520,7 +529,7 @@ caldav==1.3.8 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.7.0 +colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -543,15 +552,15 @@ crownstone-uart==2.1.0 # homeassistant.components.datadog datadog==0.15.0 -# homeassistant.components.metoffice -datapoint==0.9.8;python_version<'3.12' - # homeassistant.components.bluetooth -dbus-fast==2.21.0 +dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 +# homeassistant.components.ecovacs +deebot-client==5.1.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect @@ -591,7 +600,7 @@ dropmqttapi==1.0.2 dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.6 +dwdwfsapi==1.0.7 # homeassistant.components.dynalite dynalite-devices==0.1.47 @@ -603,13 +612,13 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.1.0 +easyenergy==2.1.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.1 +elgato==5.1.2 # homeassistant.components.elkm1 elkm1-lib==2.2.6 @@ -617,6 +626,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.elvia +elvia==0.1.0 + # homeassistant.components.emulated_roku emulated-roku==0.2.1 @@ -635,6 +647,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 @@ -716,7 +731,7 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss-generic-client==0.6 +georss-generic-client==0.8 # homeassistant.components.ign_sismologia georss-ign-sismologia-client==0.6 @@ -763,7 +778,13 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.24.0 +govee-ble==0.31.0 + +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 + +# homeassistant.components.gpsd +gps3==0.33.3 # homeassistant.components.gree greeclimate==1.4.1 @@ -803,13 +824,13 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.1.0 +habluetooth==2.4.0 # homeassistant.components.cloud -hass-nabucasa==0.75.1 +hass-nabucasa==0.76.0 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.6.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -820,6 +841,9 @@ here-routing==0.2.0 # homeassistant.components.here_travel_time here-transit==1.2.0 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 @@ -828,13 +852,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.39 +holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240104.0 +home-assistant-frontend==20240207.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.2 +home-assistant-intents==2024.2.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -842,15 +866,15 @@ homeconnect==0.7.2 # homeassistant.components.homematicip_cloud homematicip==1.0.16 -# homeassistant.components.home_plus_control -homepluscontrol==0.0.5 - # homeassistant.components.remember_the_milk httplib2==0.20.4 # homeassistant.components.huawei_lte huawei-lte-api==1.7.3 +# homeassistant.components.huum +huum==0.7.10 + # homeassistant.components.hyperion hyperion-py==0.7.5 @@ -869,7 +893,7 @@ ical==6.1.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.4 +idasen-ha==2.5 # homeassistant.components.network ifaddr==0.2.0 @@ -908,13 +932,13 @@ jellyfin-apiclient-python==1.9.2 jsonpath==0.82.2 # homeassistant.components.justnimbus -justnimbus==0.6.0 +justnimbus==0.7.3 # homeassistant.components.kegtron kegtron-ble==0.4.0 # homeassistant.components.knx -knx-frontend==2023.6.23.191712 +knx-frontend==2024.1.20.105944 # homeassistant.components.konnected konnected==1.2.0 @@ -931,6 +955,9 @@ laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 +# homeassistant.components.leaone +leaone-ble==0.1.0 + # homeassistant.components.led_ble led-ble==1.0.1 @@ -943,12 +970,12 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.life360 -life360==6.0.1 - # homeassistant.components.linear_garage_door linear-garage-door==0.2.7 +# homeassistant.components.lamarzocco +lmcloud==0.4.35 + # homeassistant.components.logi_circle logi-circle==0.2.3 @@ -961,14 +988,17 @@ loqedAPI==2.1.8 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lupusec +lupupy==0.3.2 + # homeassistant.components.scrape -lxml==4.9.4 +lxml==5.1.0 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.22.1 +matrix-nio==0.24.0 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -1016,11 +1046,14 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.19 +motionblinds==0.6.20 # homeassistant.components.motioneye motioneye-client==0.3.14 +# homeassistant.components.bang_olufsen +mozart-api==3.2.1.150.6 + # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1031,7 +1064,10 @@ mutagen==1.47.0 mutesync==0.0.1 # homeassistant.components.permobil -mypermobil==0.1.6 +mypermobil==0.1.8 + +# homeassistant.components.myuplink +myuplink==0.0.9 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 @@ -1046,10 +1082,10 @@ netmap==0.7.0.2 nettigo-air-monitor==2.2.2 # homeassistant.components.nexia -nexia==2.0.7 +nexia==2.0.8 # homeassistant.components.nextcloud -nextcloudmonitor==1.4.0 +nextcloudmonitor==1.5.0 # homeassistant.components.discord nextcord==2.0.0a8 @@ -1058,7 +1094,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.5.2 +nibe==2.8.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1117,7 +1153,7 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.1.0 +opower==0.2.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1159,7 +1195,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.35.3 +plugwise==0.36.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1181,7 +1217,7 @@ prometheus-client==0.17.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.7 +psutil==5.9.8 # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 @@ -1196,7 +1232,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.4 +py-aosmith==1.0.6 # homeassistant.components.canary py-canary==0.5.3 @@ -1222,6 +1258,9 @@ py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.ecovacs +py-sucks==0.9.8 + # homeassistant.components.synology_dsm py-synologydsm-api==2.1.4 @@ -1235,7 +1274,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.1.1 +pyDuotecno==2024.1.2 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1269,7 +1308,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1314,7 +1353,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 @@ -1329,7 +1368,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.17.0 +pyenphase==1.19.0 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1359,7 +1398,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.9 +pyfritzhome==0.6.10 # homeassistant.components.ifttt pyfttt==0.3 @@ -1386,7 +1425,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.2 +pyinsteon==1.5.3 # homeassistant.components.ipma pyipma==3.0.7 @@ -1404,7 +1443,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.6 +pyjvcprojector==1.0.9 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1443,7 +1482,10 @@ pylitejet==0.6.2 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.3 +pylutron-caseta==0.19.0 + +# homeassistant.components.lutron +pylutron==0.2.8 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1452,16 +1494,16 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.melcloud -pymelcloud==2.5.8 +pymelcloud==2.5.9 # homeassistant.components.meteoclimatic -pymeteoclimatic==0.0.6 +pymeteoclimatic==0.1.0 # homeassistant.components.mochad pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.4 +pymodbus==3.6.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1629,20 +1671,23 @@ pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.tankerkoenig -pytankerkoenig==0.0.6 - # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.13 + # homeassistant.components.motionmount python-MotionMount==0.3.1 # homeassistant.components.awair python-awair==0.2.4 +# homeassistant.components.bring +python-bring-api==3.0.0 + # homeassistant.components.bsblan -python-bsblan==0.5.16 +python-bsblan==0.5.18 # homeassistant.components.ecobee python-ecobee-api==0.2.17 @@ -1650,8 +1695,11 @@ python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 +# homeassistant.components.analytics_insights +python-homeassistant-analytics==0.6.0 + # homeassistant.components.homewizard -python-homewizard-energy==4.1.0 +python-homewizard-energy==4.3.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1660,10 +1708,10 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.1.1 +python-matter-server==5.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1679,7 +1727,7 @@ python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.5.0 +python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -1687,18 +1735,24 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.roborock -python-roborock==0.38.0 +python-roborock==0.39.1 # homeassistant.components.smarttub python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16 +python-songpal==0.16.1 # homeassistant.components.tado python-tado==0.17.4 +# homeassistant.components.technove +python-technove==1.2.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -1709,6 +1763,7 @@ pytile==2023.04.0 pytomorrowio==0.3.6 # homeassistant.components.traccar +# homeassistant.components.traccar_server pytraccar==2.0.0 # homeassistant.components.tradfri @@ -1718,7 +1773,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.2 +pytrafikverket==0.3.10 # homeassistant.components.v2c pytrydan==0.4.0 @@ -1754,7 +1809,7 @@ pyweatherflowudp==1.4.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.3.0 +pywemo==1.4.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -1790,7 +1845,7 @@ rapt-ble==0.1.2 refoss-ha==1.2.0 # homeassistant.components.rainmachine -regenmaschine==2023.06.0 +regenmaschine==2024.01.0 # homeassistant.components.renault renault-api==0.2.1 @@ -1805,10 +1860,13 @@ reolink-aio==0.8.7 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.5 +ring-doorbell[listen]==0.8.7 # homeassistant.components.roku -rokuecp==0.18.1 +rokuecp==0.19.0 + +# homeassistant.components.romy +romy==0.0.7 # homeassistant.components.roomba roombapy==1.6.10 @@ -1854,10 +1912,10 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.2 # homeassistant.components.sentry -sentry-sdk==1.37.1 +sentry-sdk==1.39.2 # homeassistant.components.sfr_box sfrbox-api==0.0.8 @@ -1872,7 +1930,7 @@ simplehound==0.3 simplepush==2.2.3 # homeassistant.components.simplisafe -simplisafe-python==2023.08.0 +simplisafe-python==2024.01.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1887,7 +1945,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.30.0 +soco==0.30.2 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1899,7 +1957,7 @@ solax==0.3.2 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.2 +sonos-websocket==0.1.3 # homeassistant.components.marytts speak2mary==1.4.0 @@ -1953,13 +2011,13 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.3 +sunweg==2.1.0 # homeassistant.components.surepetcare surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.3.0 +switchbot-api==2.0.0 # homeassistant.components.system_bridge systembridgeconnector==3.10.0 @@ -1976,8 +2034,11 @@ temescal==0.5 # homeassistant.components.temper temperusb==1.6.1 +# homeassistant.components.teslemetry +tesla-fleet-api==0.2.3 + # homeassistant.components.powerwall -tesla-powerwall==0.3.19 +tesla-powerwall==0.5.1 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 @@ -1989,7 +2050,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.5.0 +thermopro-ble==0.9.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -2016,7 +2077,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 @@ -2037,7 +2098,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.15 +universal-silabs-flasher==0.0.18 # homeassistant.components.upb upb-lib==0.5.4 @@ -2057,13 +2118,13 @@ uvcclient==0.11.0 vacuum-map-parser-roborock==0.1.1 # homeassistant.components.vallox -vallox-websocket-api==4.0.2 +vallox-websocket-api==4.0.3 # homeassistant.components.rdw vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.11.0 +velbus-aio==2023.12.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2081,7 +2142,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vulcan -vulcan-api==2.3.0 +vulcan-api==2.3.2 # homeassistant.components.vultr vultr==0.1.2 @@ -2115,19 +2176,19 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.4.0 +wyoming==1.5.2 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.1 +xiaomi-ble==0.23.1 # homeassistant.components.knx -xknx==2.11.2 +xknx==2.12.0 # homeassistant.components.knx -xknxproject==3.4.0 +xknxproject==3.5.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2142,7 +2203,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.4.0 +yalexs-ble==2.4.1 # homeassistant.components.august yalexs==1.10.0 @@ -2163,7 +2224,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.3 +zamg==0.3.5 # homeassistant.components.zeroconf zeroconf==0.131.0 @@ -2172,10 +2233,10 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.109 +zha-quirks==0.0.111 # homeassistant.components.zha -zigpy-deconz==0.22.4 +zigpy-deconz==0.23.0 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2187,7 +2248,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.7 +zigpy==0.62.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 diff --git a/script/const.py b/script/const.py new file mode 100644 index 00000000000..de9b559e634 --- /dev/null +++ b/script/const.py @@ -0,0 +1,4 @@ +"""Script constants.""" +from pathlib import Path + +COMPONENT_DIR = Path("homeassistant/components") diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 15bcbf1b7f3..3e61a266ae1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -138,9 +138,9 @@ iso4217!=1.10.20220401 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.1.0 or later required to avoid import errors when -# cryptography 40.0.1 is installed with botocore -pyOpenSSL>=23.1.0 +# pyOpenSSL 24.0.0 or later required to avoid import errors when +# cryptography 42.0.0 is installed with botocore +pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels @@ -175,16 +175,15 @@ get-mac==1000000000.0.0 # 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 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. pandas==2.1.4 + +# chacha20poly1305-reuseable==0.12.0 is incompatible with cryptography==42.0.x +chacha20poly1305-reuseable>=0.12.1 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c454c69d141..308c006defc 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -17,6 +17,7 @@ from . import ( dependencies, dhcp, docker, + icons, json, manifest, metadata, @@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [ config_schema, dependencies, dhcp, + icons, json, manifest, mqtt, diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index c9d81424229..2856c1ee0ea 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -34,12 +34,19 @@ RUN \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ - && \ + && if [ "${{BUILD_ARCH}}" = "i386" ]; then \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + linux32 pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt; \ + else \ 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 + -r homeassistant/requirements_all.txt; \ + fi ## Setup Home Assistant Core COPY . homeassistant/ diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py new file mode 100644 index 00000000000..60dc2b79f56 --- /dev/null +++ b/script/hassfest/icons.py @@ -0,0 +1,139 @@ +"""Validate integration icon translation files.""" +from __future__ import annotations + +from typing import Any + +import orjson +import voluptuous as vol +from voluptuous.humanize import humanize_error + +import homeassistant.helpers.config_validation as cv + +from .model import Config, Integration +from .translations import translation_key_validator + + +def icon_value_validator(value: Any) -> str: + """Validate that the icon is a valid icon.""" + value = cv.string_with_no_html(value) + if not value.startswith("mdi:"): + raise vol.Invalid( + "The icon needs to be a valid icon from Material Design Icons and start with `mdi:`" + ) + return str(value) + + +def require_default_icon_validator(value: dict) -> dict: + """Validate that a default icon is set.""" + if "_" not in value: + raise vol.Invalid( + "An entity component needs to have a default icon defined with `_`" + ) + return value + + +def ensure_not_same_as_default(value: dict) -> dict: + """Validate an icon isn't the same as its default icon.""" + for translation_key, section in value.items(): + if (default := section.get("default")) and (states := section.get("state")): + for state, icon in states.items(): + if icon == default: + raise vol.Invalid( + f"The icon for state `{translation_key}.{state}` is the" + " same as the default icon and thus can be removed" + ) + + return value + + +def icon_schema(integration_type: str) -> vol.Schema: + """Create a icon schema.""" + + state_validator = cv.schema_with_slug_keys( + icon_value_validator, + slug_validator=translation_key_validator, + ) + + def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: + return { + marker("default"): icon_value_validator, + vol.Optional("state"): state_validator, + vol.Optional("state_attributes"): vol.All( + cv.schema_with_slug_keys( + { + marker("default"): icon_value_validator, + marker("state"): state_validator, + }, + slug_validator=translation_key_validator, + ), + ensure_not_same_as_default, + ), + } + + schema = vol.Schema( + { + vol.Optional("services"): state_validator, + } + ) + + if integration_type in ("entity", "helper", "system"): + if integration_type != "entity": + field = vol.Optional("entity_component") + else: + field = vol.Required("entity_component") + schema = schema.extend( + { + field: vol.All( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Required), + slug_validator=vol.Any("_", cv.slug), + ), + require_default_icon_validator, + ensure_not_same_as_default, + ) + } + ) + if integration_type not in ("entity", "system"): + schema = schema.extend( + { + vol.Optional("entity"): vol.All( + cv.schema_with_slug_keys( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Optional), + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, + ), + ensure_not_same_as_default, + ) + } + ) + return schema + + +def validate_icon_file(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate icon file for integration.""" + icons_file = integration.path / "icons.json" + if not icons_file.is_file(): + return + + name = str(icons_file.relative_to(integration.path)) + + try: + icons = orjson.loads(icons_file.read_text()) + except ValueError as err: + integration.add_error("icons", f"Invalid JSON in {name}: {err}") + return + + schema = icon_schema(integration.integration_type) + + try: + schema(icons) + except vol.Invalid as err: + integration.add_error("icons", f"Invalid {name}: {humanize_error(icons, err)}") + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle JSON files inside integrations.""" + for integration in integrations.values(): + validate_icon_file(config, integration) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index fa2956dd47d..738ebcb260a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -33,6 +33,7 @@ ALLOW_NAME_TRANSLATION = { "garages_amsterdam", "generic", "google_travel_time", + "holiday", "homekit_controller", "islamic_prayer_times", "local_calendar", diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py new file mode 100644 index 00000000000..1a87be04f7e --- /dev/null +++ b/script/install_integration_requirements.py @@ -0,0 +1,54 @@ +"""Install requirements for a given integration.""" + +import argparse +from pathlib import Path +import subprocess +import sys + +from .gen_requirements_all import gather_recursive_requirements +from .util import valid_integration + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser( + description="Install requirements for a given integration" + ) + parser.add_argument( + "integration", type=valid_integration, help="Integration to target." + ) + + arguments = parser.parse_args() + + return arguments + + +def main() -> int | None: + """Install requirements for a given integration.""" + if not Path("requirements_all.txt").is_file(): + print("Run from project root") + return 1 + + args = get_arguments() + + requirements = gather_recursive_requirements(args.integration) + + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "-c", + "homeassistant/package_constraints.txt", + "-U", + *requirements, + ] + print(" ".join(cmd)) + subprocess.run( + cmd, + check=True, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index ddbd1189e11..c303fc2c247 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -4,24 +4,15 @@ from pathlib import Path import subprocess import sys +from script.util import valid_integration + from . import docs, error, gather_info, generate -from .const import COMPONENT_DIR TEMPLATES = [ p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir() ] -def valid_integration(integration): - """Test if it's a valid integration.""" - if not (COMPONENT_DIR / integration).exists(): - raise argparse.ArgumentTypeError( - f"The integration {integration} does not exist." - ) - - return integration - - def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser(description="Home Assistant Scaffolder") diff --git a/script/util.py b/script/util.py new file mode 100644 index 00000000000..b7c37c72102 --- /dev/null +++ b/script/util.py @@ -0,0 +1,15 @@ +"""Utility functions for the scaffold script.""" + +import argparse + +from .const import COMPONENT_DIR + + +def valid_integration(integration): + """Test if it's a valid integration.""" + if not (COMPONENT_DIR / integration).exists(): + raise argparse.ArgumentTypeError( + f"The integration {integration} does not exist." + ) + + return integration diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index a92d41a8c5f..016ce767bad 100644 --- a/tests/auth/providers/test_command_line.py +++ b/tests/auth/providers/test_command_line.py @@ -13,9 +13,11 @@ from homeassistant.const import CONF_TYPE @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py index 6054b7937c6..ceb8b02ae65 100644 --- a/tests/auth/providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -9,9 +9,11 @@ from homeassistant.auth.providers import insecure_example @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 3d89c577ebf..75c4f733285 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -14,9 +14,11 @@ CONFIG = {"type": "legacy_api_password", "api_password": "test-password"} @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index a098eea28e0..3ccff990b9c 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -16,9 +16,11 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 860abe76577..858d4d082b1 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,10 +1,15 @@ """Tests for the auth store.""" import asyncio +from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun import freeze_time +import pytest + from homeassistant.auth import auth_store from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util async def test_loading_no_group_data_format( @@ -67,6 +72,7 @@ async def test_loading_no_group_data_format( } store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -165,6 +171,7 @@ async def test_loading_all_access_group_data_format( } store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -205,6 +212,7 @@ async def test_loading_empty_data( ) -> None: """Test we correctly load with no existing data.""" store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -232,7 +240,7 @@ async def test_system_groups_store_id_and_name( Name is stored so that we remain backwards compat with < 0.82. """ store = auth_store.AuthStore(hass) - await store._async_load() + await store.async_load() data = store._data_to_save() assert len(data["users"]) == 0 assert data["groups"] == [ @@ -242,8 +250,8 @@ async def test_system_groups_store_id_and_name( ] -async def test_loading_race_condition(hass: HomeAssistant) -> None: - """Test only one storage load called when concurrent loading occurred .""" +async def test_loading_only_once(hass: HomeAssistant) -> None: + """Test only one storage load is allowed.""" store = auth_store.AuthStore(hass) with patch( "homeassistant.helpers.entity_registry.async_get" @@ -252,9 +260,77 @@ async def test_loading_race_condition(hass: HomeAssistant) -> None: ) as mock_dev_registry, patch( "homeassistant.helpers.storage.Store.async_load", return_value=None ) as mock_load: + await store.async_load() + with pytest.raises(RuntimeError, match="Auth storage is already loaded"): + await store.async_load() + results = await asyncio.gather(store.async_get_users(), store.async_get_users()) mock_ent_registry.assert_called_once_with(hass) mock_dev_registry.assert_called_once_with(hass) mock_load.assert_called_once_with() assert results[0] == results[1] + + +async def test_add_expire_at_property( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we correctly add expired_at property if not existing.""" + now = dt_util.utcnow() + with freeze_time(now): + hass_storage[auth_store.STORAGE_KEY] = { + "version": 1, + "data": { + "credentials": [], + "users": [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + }, + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": str(now - timedelta(days=10)), + "token": "some-token", + "user_id": "user-id", + "version": "1.2.3", + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id2", + "jwt_key": "some-key2", + "token": "some-token", + "user_id": "user-id", + }, + ], + }, + } + + store = auth_store.AuthStore(hass) + await store.async_load() + + users = await store.async_get_users() + + assert len(users[0].refresh_tokens) == 2 + token1, token2 = users[0].refresh_tokens.values() + assert token1.expire_at + assert token1.expire_at == now.timestamp() + timedelta(days=80).total_seconds() + assert token2.expire_at + assert token2.expire_at == now.timestamp() + timedelta(days=90).total_seconds() diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 9e9b48a07f6..b561b17112b 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -26,6 +26,7 @@ from tests.common import ( CLIENT_ID, MockUser, async_capture_events, + async_fire_time_changed, ensure_auth_manager_loaded, flush_store, ) @@ -343,6 +344,7 @@ async def test_saving_loading( await flush_store(manager._store._store) store2 = auth_store.AuthStore(hass) + await store2.async_load() users = await store2.async_get_users() assert len(users) == 1 assert users[0].permissions == user.permissions @@ -370,7 +372,7 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None assert refresh_token.client_id == CLIENT_ID access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is refresh_token + assert manager.async_validate_access_token(access_token) is refresh_token # We patch time directly here because we want the access token to be created with # an expired time, but we do not want to freeze time so that jwt will compare it @@ -384,7 +386,7 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None ): access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_generating_system_user(hass: HomeAssistant) -> None: @@ -405,6 +407,8 @@ async def test_generating_system_user(hass: HomeAssistant) -> None: assert not user.local_only assert token is not None assert token.client_id is None + assert token.token_type == auth.models.TOKEN_TYPE_SYSTEM + assert token.expire_at is None await hass.async_block_till_done() assert len(events) == 1 @@ -420,6 +424,8 @@ async def test_generating_system_user(hass: HomeAssistant) -> None: assert user.local_only assert token is not None assert token.client_id is None + assert token.token_type == auth.models.TOKEN_TYPE_SYSTEM + assert token.expire_at is None await hass.async_block_till_done() assert len(events) == 2 @@ -473,6 +479,8 @@ async def test_refresh_token_with_specific_access_token_expiration( assert token is not None assert token.client_id == CLIENT_ID assert token.access_token_expiration == timedelta(days=100) + assert token.token_type == auth.models.TOKEN_TYPE_NORMAL + assert token.expire_at is not None async def test_refresh_token_type(hass: HomeAssistant) -> None: @@ -514,6 +522,7 @@ async def test_refresh_token_type_long_lived_access_token(hass: HomeAssistant) - assert token.client_name == "GPS LOGGER" assert token.client_icon == "mdi:home" assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + assert token.expire_at is None async def test_refresh_token_provider_validation(mock_hass) -> None: @@ -564,17 +573,81 @@ async def test_cannot_deactive_owner(mock_hass) -> None: await manager.async_deactivate_user(owner) -async def test_remove_refresh_token(mock_hass) -> None: +async def test_remove_refresh_token(hass: HomeAssistant) -> None: """Test that we can remove a refresh token.""" - manager = await auth.auth_manager_from_config(mock_hass, [], []) + manager = await auth.auth_manager_from_config(hass, [], []) user = MockUser().add_to_auth_manager(manager) refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) access_token = manager.async_create_access_token(refresh_token) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) - assert await manager.async_get_refresh_token(refresh_token.id) is None - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_get_refresh_token(refresh_token.id) is None + assert manager.async_validate_access_token(access_token) is None + + +async def test_remove_expired_refresh_token(hass: HomeAssistant) -> None: + """Test that expired refresh tokens are deleted.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + now = dt_util.utcnow() + with freeze_time(now): + refresh_token1 = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token1.expire_at + == now.timestamp() + timedelta(days=90).total_seconds() + ) + + with freeze_time(now + timedelta(days=30)): + async_fire_time_changed(hass, now + timedelta(days=30)) + refresh_token2 = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token2.expire_at + == now.timestamp() + timedelta(days=120).total_seconds() + ) + + with freeze_time(now + timedelta(days=89, hours=23)): + async_fire_time_changed(hass, now + timedelta(days=89, hours=23)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) + assert manager.async_get_refresh_token(refresh_token2.id) + + with freeze_time(now + timedelta(days=90, seconds=5)): + async_fire_time_changed(hass, now + timedelta(days=90, seconds=5)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) is None + assert manager.async_get_refresh_token(refresh_token2.id) + + with freeze_time(now + timedelta(days=120, seconds=5)): + async_fire_time_changed(hass, now + timedelta(days=120, seconds=5)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) is None + assert manager.async_get_refresh_token(refresh_token2.id) is None + + +async def test_update_expire_at_refresh_token(hass: HomeAssistant) -> None: + """Test that expire at is updated when refresh token is used.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + now = dt_util.utcnow() + with freeze_time(now): + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token.expire_at + == now.timestamp() + timedelta(days=90).total_seconds() + ) + + with freeze_time(now + timedelta(days=30)): + async_fire_time_changed(hass, now + timedelta(days=30)) + await hass.async_block_till_done() + assert manager.async_create_access_token(refresh_token) + await hass.async_block_till_done() + assert ( + refresh_token.expire_at + == now.timestamp() + + timedelta(days=30).total_seconds() + + timedelta(days=90).total_seconds() + ) async def test_register_revoke_token_callback(mock_hass) -> None: @@ -590,7 +663,7 @@ async def test_register_revoke_token_callback(mock_hass) -> None: called = True manager.async_register_revoke_token_callback(refresh_token.id, cb) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert called @@ -609,7 +682,7 @@ async def test_unregister_revoke_token_callback(mock_hass) -> None: unregister = manager.async_register_revoke_token_callback(refresh_token.id, cb) unregister() - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert not called @@ -663,7 +736,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: access_token = manager.async_create_access_token(refresh_token) jwt_key = refresh_token.jwt_key - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id with pytest.raises(ValueError): @@ -674,9 +747,9 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: access_token_expiration=timedelta(days=3000), ) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert refresh_token.id not in user.refresh_tokens - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt is None, "Previous issued access token has been invoked" refresh_token_2 = await manager.async_create_refresh_token( @@ -693,7 +766,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: assert access_token != access_token_2 assert jwt_key != jwt_key_2 - rt = await manager.async_validate_access_token(access_token_2) + rt = manager.async_validate_access_token(access_token_2) jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token_2.id assert ( @@ -1143,7 +1216,7 @@ async def test_access_token_with_invalid_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we corrupt the signature @@ -1153,7 +1226,7 @@ async def test_access_token_with_invalid_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1170,7 +1243,7 @@ async def test_access_token_with_null_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we make the signature all nulls @@ -1180,7 +1253,7 @@ async def test_access_token_with_null_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1197,7 +1270,7 @@ async def test_access_token_with_empty_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we make the signature all nulls @@ -1206,7 +1279,7 @@ async def test_access_token_with_empty_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1224,17 +1297,17 @@ async def test_access_token_with_empty_key(mock_hass) -> None: access_token = manager.async_create_access_token(refresh_token) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) # Now remove the token from the keyring # so we will get an empty key - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_reject_access_token_with_impossible_large_size(mock_hass) -> None: """Test rejecting access tokens with impossible sizes.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token("a" * 10000) is None + assert manager.async_validate_access_token("a" * 10000) is None async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: @@ -1244,7 +1317,7 @@ async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: b"invalid", b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token(token_with_invalid_json) is None + assert manager.async_validate_access_token(token_with_invalid_json) is None async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: @@ -1254,7 +1327,7 @@ async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: b'["invalid"]', b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token(token_not_a_dict_json) is None + assert manager.async_validate_access_token(token_not_a_dict_json) is None async def test_access_token_that_expires_soon(mock_hass) -> None: @@ -1271,11 +1344,11 @@ async def test_access_token_that_expires_soon(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id with freeze_time(now + timedelta(minutes=1)): - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_access_token_from_the_future(mock_hass) -> None: @@ -1295,8 +1368,8 @@ async def test_access_token_from_the_future(mock_hass) -> None: ) access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None with freeze_time(now + timedelta(days=365)): - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py index 1c518cf061d..3f0ad7acc1d 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -26,3 +26,37 @@ def test_permissions_merged() -> None: assert user.permissions.check_entity("switch.bla", "read") is True assert user.permissions.check_entity("light.kitchen", "read") is True assert user.permissions.check_entity("light.not_kitchen", "read") is False + + +def test_cache_cleared_on_group_change() -> None: + """Test we clear the cache when a group changes.""" + group = models.Group( + name="Test Group", policy={"entities": {"domains": {"switch": True}}} + ) + admin_group = models.Group( + name="Admin group", id=models.GROUP_ID_ADMIN, policy={"entities": {}} + ) + user = models.User( + name="Test User", perm_lookup=None, groups=[group], is_active=True + ) + # Make sure we cache instance + assert user.permissions is user.permissions + + # Make sure we cache is_admin + assert user.is_admin is user.is_admin + assert user.is_active is True + + user.groups = [] + assert user.groups == [] + assert user.is_admin is False + + user.is_owner = True + assert user.is_admin is True + user.is_owner = False + + assert user.is_admin is False + user.groups = [admin_group] + assert user.is_admin is True + + user.is_active = False + assert user.is_admin is False diff --git a/tests/common.py b/tests/common.py index 85193022e4f..1b40904d5e2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -74,7 +74,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder +from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe @@ -285,7 +285,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None - hass.state = CoreState.running + hass.set_state(CoreState.running) @callback def clear_instance(event): @@ -507,6 +507,11 @@ def load_json_object_fixture( return json_loads_object(load_fixture(filename, integration)) +def json_round_trip(obj: Any) -> Any: + """Round trip an object to JSON.""" + return json_loads(json_dumps(obj)) + + def mock_state_change_event( hass: HomeAssistant, new_state: State, old_state: State | None = None ) -> None: @@ -664,7 +669,7 @@ class MockUser(auth_models.User): def mock_policy(self, policy): """Mock a policy for a user.""" - self._permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) + self.permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) async def register_auth_provider( @@ -934,12 +939,10 @@ class MockConfigEntry(config_entries.ConfigEntry): def add_to_hass(self, hass: HomeAssistant) -> None: """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self - hass.config_entries._domain_index.setdefault(self.domain, []).append(self) def add_to_manager(self, manager: config_entries.ConfigEntries) -> None: """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self - manager._domain_index.setdefault(self.domain, []).append(self) def patch_yaml_files(files_dict, endswith=True): @@ -1316,12 +1319,13 @@ 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.""" - handler = config_entries.HANDLERS.get(domain) + original_handler = config_entries.HANDLERS.get(domain) config_entries.HANDLERS[domain] = config_flow _LOGGER.info("Adding mock config flow: %s", domain) yield - if handler: - config_entries.HANDLERS[domain] = handler + config_entries.HANDLERS.pop(domain) + if original_handler: + config_entries.HANDLERS[domain] = original_handler def mock_integration( diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index d208b6302bc..ae7ed51e086 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -79,6 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr index 9e21d0ede17..28addf01ecd 100644 --- a/tests/components/advantage_air/snapshots/test_climate.ambr +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -40,7 +40,7 @@ ]), 'max_temp': 32, 'min_temp': 16, - 'supported_features': , + 'supported_features': , 'target_temp_high': 24, 'target_temp_low': 20, 'target_temp_step': 1, diff --git a/tests/components/aemet/test_coordinator.py b/tests/components/aemet/test_coordinator.py index 067fc30a2c0..a91256a9518 100644 --- a/tests/components/aemet/test_coordinator.py +++ b/tests/components/aemet/test_coordinator.py @@ -4,9 +4,7 @@ from unittest.mock import patch from aemet_opendata.exceptions import AemetError from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.aemet.weather_update_coordinator import ( - WEATHER_UPDATE_INTERVAL, -) +from homeassistant.components.aemet.coordinator import WEATHER_UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 7b6f02f8b06..46b08f929c9 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -93,7 +93,7 @@ async def test_aemet_weather_create_sensors( assert state.state == "1004.4" state = hass.states.get("sensor.aemet_rain") - assert state.state == "1.8" + assert state.state == "7.0" state = hass.states.get("sensor.aemet_rain_probability") assert state.state == "100" @@ -132,10 +132,10 @@ async def test_aemet_weather_create_sensors( assert state.state == "2021-01-09T11:47:45+00:00" state = hass.states.get("sensor.aemet_wind_bearing") - assert state.state == "90.0" + assert state.state == "122.0" state = hass.states.get("sensor.aemet_wind_max_speed") - assert state.state == "24" + assert state.state == "12.2" state = hass.states.get("sensor.aemet_wind_speed") - assert state.state == "15" + assert state.state == "3.2" diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 695087bb738..1f323413174 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -7,9 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.aemet.const import ATTRIBUTION, DOMAIN -from homeassistant.components.aemet.weather_update_coordinator import ( - WEATHER_UPDATE_INTERVAL, -) +from homeassistant.components.aemet.coordinator import WEATHER_UPDATE_INTERVAL from homeassistant.components.weather import ( ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..36ae402f4f4 --- /dev/null +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -0,0 +1,583 @@ +# serializer version: 1 +# name: test_sensor[sensor.home_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_carbon_monoxide', + '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': None, + 'original_name': 'Carbon monoxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'co', + 'unique_id': '123-456-co', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'friendly_name': 'Home Carbon monoxide', + 'limit': 4000, + 'percent': 4, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_carbon_monoxide', + 'last_changed': , + 'last_updated': , + 'state': '162.49', + }) +# --- +# name: test_sensor[sensor.home_common_air_quality_index-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.home_common_air_quality_index', + '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': None, + 'original_name': 'Common air quality index', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'caqi', + 'unique_id': '123-456-caqi', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_sensor[sensor.home_common_air_quality_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'advice': 'Catch your breath!', + 'attribution': 'Data provided by Airly', + 'description': 'Great air here today!', + 'friendly_name': 'Home Common air quality index', + 'level': 'very low', + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.home_common_air_quality_index', + 'last_changed': , + 'last_updated': , + 'state': '7.29', + }) +# --- +# name: test_sensor[sensor.home_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_humidity', + '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': 'Humidity', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'humidity', + 'friendly_name': 'Home Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_humidity', + 'last_changed': , + 'last_updated': , + 'state': '68.35', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_nitrogen_dioxide', + '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': 'Nitrogen dioxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-no2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'nitrogen_dioxide', + 'friendly_name': 'Home Nitrogen dioxide', + 'limit': 25, + 'percent': 64, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '16.04', + }) +# --- +# name: test_sensor[sensor.home_ozone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ozone', + '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': 'Ozone', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-o3', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'ozone', + 'friendly_name': 'Home Ozone', + 'limit': 100, + 'percent': 42, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ozone', + 'last_changed': , + 'last_updated': , + 'state': '41.52', + }) +# --- +# name: test_sensor[sensor.home_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pm1', + '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': 'PM1', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm1', + 'friendly_name': 'Home PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm1', + 'last_changed': , + 'last_updated': , + 'state': '2.83', + }) +# --- +# name: test_sensor[sensor.home_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pm10', + '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': 'PM10', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm10', + 'friendly_name': 'Home PM10', + 'limit': 45, + 'percent': 14, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm10', + 'last_changed': , + 'last_updated': , + 'state': '6.06', + }) +# --- +# name: test_sensor[sensor.home_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pm2_5', + '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': 'PM2.5', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm25', + 'friendly_name': 'Home PM2.5', + 'limit': 15, + 'percent': 29, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '4.37', + }) +# --- +# name: test_sensor[sensor.home_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pressure', + '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': 'Pressure', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pressure', + 'friendly_name': 'Home Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1019.86', + }) +# --- +# name: test_sensor[sensor.home_sulphur_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_sulphur_dioxide', + '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': 'Sulphur dioxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-so2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'sulphur_dioxide', + 'friendly_name': 'Home Sulphur dioxide', + 'limit': 40, + 'percent': 35, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_sulphur_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '13.97', + }) +# --- +# name: test_sensor[sensor.home_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.home_temperature', + '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': 'Temperature', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'temperature', + 'friendly_name': 'Home Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_temperature', + 'last_changed': , + 'last_updated': , + 'state': '14.37', + }) +# --- diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 35d7eb86c04..2a2bf9fb923 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -1,27 +1,12 @@ """Test sensor of Airly integration.""" from datetime import timedelta from http import HTTPStatus +from unittest.mock import patch from airly.exceptions import AirlyError +from syrupy import SnapshotAssertion -from homeassistant.components.airly.sensor import ATTRIBUTION -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - PERCENTAGE, - STATE_UNAVAILABLE, - UnitOfPressure, - UnitOfTemperature, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -37,171 +22,19 @@ async def test_sensor( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test states of the sensor.""" - await init_integration(hass, aioclient_mock) + with patch("homeassistant.components.airly.PLATFORMS", [Platform.SENSOR]): + entry = await init_integration(hass, aioclient_mock) - state = hass.states.get("sensor.home_common_air_quality_index") - assert state - assert state.state == "7.29" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - entry = entity_registry.async_get("sensor.home_common_air_quality_index") - assert entry - assert entry.unique_id == "123-456-caqi" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_humidity") - assert state - assert state.state == "68.35" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_humidity") - assert entry - assert entry.unique_id == "123-456-humidity" - assert entry.options["sensor"] == {"suggested_display_precision": 1} - - state = hass.states.get("sensor.home_pm1") - assert state - assert state.state == "2.83" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm1") - assert entry - assert entry.unique_id == "123-456-pm1" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4.37" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm2_5") - assert entry - assert entry.unique_id == "123-456-pm25" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pm10") - assert state - assert state.state == "6.06" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm10") - assert entry - assert entry.unique_id == "123-456-pm10" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_carbon_monoxide") - assert state - assert state.state == "162.49" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - entry = entity_registry.async_get("sensor.home_carbon_monoxide") - assert entry - assert entry.unique_id == "123-456-co" - - state = hass.states.get("sensor.home_nitrogen_dioxide") - assert state - assert state.state == "16.04" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") - assert entry - assert entry.unique_id == "123-456-no2" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_ozone") - assert state - assert state.state == "41.52" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_ozone") - assert entry - assert entry.unique_id == "123-456-o3" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_sulphur_dioxide") - assert state - assert state.state == "13.97" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_sulphur_dioxide") - assert entry - assert entry.unique_id == "123-456-so2" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pressure") - assert state - assert state.state == "1019.86" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pressure") - assert entry - assert entry.unique_id == "123-456-pressure" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_temperature") - assert state - assert state.state == "14.37" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_temperature") - assert entry - assert entry.unique_id == "123-456-temperature" - assert entry.options["sensor"] == {"suggested_display_precision": 1} + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_availability( diff --git a/tests/components/airtouch5/__init__.py b/tests/components/airtouch5/__init__.py new file mode 100644 index 00000000000..2b76786e7e5 --- /dev/null +++ b/tests/components/airtouch5/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airtouch 5 integration.""" diff --git a/tests/components/airtouch5/conftest.py b/tests/components/airtouch5/conftest.py new file mode 100644 index 00000000000..836ce81301a --- /dev/null +++ b/tests/components/airtouch5/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Airtouch 5 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.airtouch5.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airtouch5/test_config_flow.py b/tests/components/airtouch5/test_config_flow.py new file mode 100644 index 00000000000..4f608fd4788 --- /dev/null +++ b/tests/components/airtouch5/test_config_flow.py @@ -0,0 +1,62 @@ +"""Test the Airtouch 5 config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.airtouch5.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_success(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"] is None + + host = "1.1.1.1" + + with patch( + "airtouch5py.airtouch5_simple_client.Airtouch5SimpleClient.test_connection", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": host, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == host + assert result2["data"] == { + "host": host, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_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( + "airtouch5py.airtouch5_simple_client.Airtouch5SimpleClient.test_connection", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4a7217a08c5..d1a8d74cc08 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -324,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({ @@ -398,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', @@ -445,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 6924344a092..98ff7c65478 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -6,6 +6,14 @@ from unittest.mock import patch 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/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index b83bdb794a8..5011fee8838 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -5,7 +5,11 @@ from unittest.mock import patch import pytest from homeassistant.components.alexa import smart_home -from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ClimateEntityFeature, + 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 @@ -923,6 +927,52 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_on_off_climate_state(hass: HomeAssistant) -> None: + """Test ThermostatController with on/off features reports state correctly.""" + on_off_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + for auto_modes in (HVACMode.HEAT,): + hass.states.async_set( + "climate.onoff", + auto_modes, + { + "friendly_name": "Climate Downstairs", + "supported_features": on_off_features, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "climate.onoff") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "HEAT") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_modes in [HVACMode.OFF]: + hass.states.async_set( + "climate.onoff", + off_modes, + { + "friendly_name": "Climate Downstairs", + "supported_features": on_off_features, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "climate.onoff") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "OFF") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + 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): diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 87aab24a3b1..c7949253af0 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -1,10 +1,11 @@ """Test Alexa entity representation.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components.alexa import smart_home -from homeassistant.const import EntityCategory, __version__ +from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -75,7 +76,7 @@ async def test_categorized_hidden_entities( async def test_serialize_discovery(hass: HomeAssistant) -> None: - """Test we handle an interface raising unexpectedly during serialize discovery.""" + """Test we can serialize a discovery.""" request = get_new_request("Alexa.Discovery", "Discover") hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"}) @@ -94,6 +95,82 @@ async def test_serialize_discovery(hass: HomeAssistant) -> None: } +async def test_serialize_discovery_partly_fails( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we can partly serialize a discovery.""" + + async def _mock_discovery() -> dict[str, Any]: + request = get_new_request("Alexa.Discovery", "Discover") + hass.states.async_set("switch.bla", "on", {"friendly_name": "My Switch"}) + hass.states.async_set("fan.bla", "on", {"friendly_name": "My Fan"}) + hass.states.async_set( + "humidifier.bla", "on", {"friendly_name": "My Humidifier"} + ) + hass.states.async_set( + "sensor.bla", + "20.1", + { + "friendly_name": "Livingroom temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS, + "device_class": "temperature", + }, + ) + return await smart_home.async_handle_message( + hass, get_default_config(hass), request + ) + + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 4 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "fan#bla", "humidifier#bla", "sensor#bla"] + ) + + # Simulate fetching the interfaces fails for fan entity + with patch( + "homeassistant.components.alexa.entities.FanCapabilities.interfaces", + side_effect=TypeError(), + ): + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 3 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "humidifier#bla", "sensor#bla"] + ) + assert "Unable to serialize fan.bla for discovery" in caplog.text + caplog.clear() + + # Simulate serializing properties fails for sensor entity + with patch( + "homeassistant.components.alexa.entities.SensorCapabilities.default_display_categories", + side_effect=ValueError(), + ): + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 3 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "humidifier#bla", "fan#bla"] + ) + assert "Unable to serialize sensor.bla for discovery" in caplog.text + caplog.clear() + + async def test_serialize_discovery_recovers( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index ff8fef43a66..97b8bac4cd1 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature @@ -20,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .test_common import ( MockConfig, @@ -3118,6 +3119,136 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_onoff_thermostat(hass: HomeAssistant) -> None: + """Test onoff thermostat discovery.""" + on_off_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + hass.config.units = METRIC_SYSTEM + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": 20.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 19.0, + "friendly_name": "Test Thermostat", + "supported_features": on_off_features, + "hvac_modes": ["auto"], + "min_temp": 7, + "max_temp": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "COOL") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 20.0, "scale": "CELSIUS"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 19.0, "scale": "CELSIUS"} + ) + + thermostat_capability = get_capability(capabilities, "Alexa.ThermostatController") + assert thermostat_capability is not None + configuration = thermostat_capability["configuration"] + assert configuration["supportsScheduling"] is False + + supported_modes = ["AUTO"] + for mode in supported_modes: + assert mode in configuration["supportedModes"] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpoint": {"value": 21.0, "scale": "CELSIUS"}}, + ) + assert call.data["temperature"] == 21.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 21.0, "scale": "CELSIUS"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOn", + "climate#test_thermostat", + "climate.turn_on", + hass, + ) + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOff", + "climate#test_thermostat", + "climate.turn_off", + hass, + ) + + # Test the power controller is not enabled when there is no `off` mode + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": 20.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 19.0, + "friendly_name": "Test Thermostat", + "supported_features": ClimateEntityFeature.TARGET_TEMPERATURE, + "hvac_modes": ["auto"], + "min_temp": 7, + "max_temp": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + async def test_water_heater(hass: HomeAssistant) -> None: """Test water_heater discovery.""" hass.config.units = US_CUSTOMARY_SYSTEM diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index c17593e0e2a..08d198145df 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -735,9 +735,12 @@ async def test_proactive_mode_filter_states( "off", {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - with patch.object(hass, "state", core.CoreState.stopping): - await hass.async_block_till_done() - await hass.async_block_till_done() + + current_state = hass.state + hass.set_state(core.CoreState.stopping) + await hass.async_block_till_done() + await hass.async_block_till_done() + hass.set_state(current_state) assert len(aioclient_mock.mock_calls) == 0 # unsupported entity should not report diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 6325282aff8..2624bd96d31 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -1,17 +1,18 @@ """Tests for the Amber config flow.""" from collections.abc import Generator +from datetime import date from unittest.mock import Mock, patch from amberelectric import ApiException -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus import pytest from homeassistant import data_entry_flow +from homeassistant.components.amberelectric.config_flow import filter_sites from homeassistant.components.amberelectric.const import ( CONF_SITE_ID, CONF_SITE_NAME, - CONF_SITE_NMI, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER @@ -26,29 +27,88 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture(name="invalid_key_api") def mock_invalid_key_api() -> Generator: """Return an authentication error.""" - instance = Mock() - instance.get_sites.side_effect = ApiException(status=403) - with patch("amberelectric.api.AmberApi.create", return_value=instance): - yield instance + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.side_effect = ApiException(status=403) + yield mock @pytest.fixture(name="api_error") def mock_api_error() -> Generator: """Return an authentication error.""" - instance = Mock() - instance.get_sites.side_effect = ApiException(status=500) - - with patch("amberelectric.api.AmberApi.create", return_value=instance): - yield instance + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.side_effect = ApiException(status=500) + yield mock @pytest.fixture(name="single_site_api") def mock_single_site_api() -> Generator: + """Return a single site.""" + site = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.ACTIVE, + date(2002, 1, 1), + None, + ) + + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.return_value = [site] + yield mock + + +@pytest.fixture(name="single_site_pending_api") +def mock_single_site_pending_api() -> Generator: + """Return a single site.""" + site = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.PENDING, + None, + None, + ) + + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.return_value = [site] + yield mock + + +@pytest.fixture(name="single_site_rejoin_api") +def mock_single_site_rejoin_api() -> Generator: """Return a single site.""" instance = Mock() - site = Site("01FG0AGP818PXK0DWHXJRRT2DH", "11111111111", []) - instance.get_sites.return_value = [site] + site_1 = Site( + "01HGD9QB72HB3DWQNJ6SSCGXGV", + "11111111111", + [], + "Jemena", + SiteStatus.CLOSED, + date(2002, 1, 1), + date(2002, 6, 1), + ) + site_2 = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.ACTIVE, + date(2003, 1, 1), + None, + ) + site_3 = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111112", + [], + "Jemena", + SiteStatus.CLOSED, + date(2003, 1, 1), + date(2003, 6, 1), + ) + instance.get_sites.return_value = [site_1, site_2, site_3] with patch("amberelectric.api.AmberApi.create", return_value=instance): yield instance @@ -64,6 +124,39 @@ def mock_no_site_api() -> Generator: yield instance +async def test_single_pending_site( + hass: HomeAssistant, single_site_pending_api: Mock +) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.FlowResultType.FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.FlowResultType.FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + + async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: """Test single site.""" initial_result = await hass.config_entries.flow.async_init( @@ -83,7 +176,40 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: select_site_result = await hass.config_entries.flow.async_configure( enter_api_key_result["flow_id"], - {CONF_SITE_NMI: "11111111111", CONF_SITE_NAME: "Home"}, + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + + +async def test_single_site_rejoin( + hass: HomeAssistant, single_site_rejoin_api: Mock +) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.FlowResultType.FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.FlowResultType.FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, ) # Show available sites @@ -93,7 +219,6 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: assert data assert data[CONF_API_TOKEN] == API_KEY assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" - assert data[CONF_SITE_NMI] == "11111111111" async def test_no_site(hass: HomeAssistant, no_site_api: Mock) -> None: @@ -148,3 +273,15 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None: # Goes back to the user step assert result.get("step_id") == "user" assert result.get("errors") == {"api_token": "unknown_error"} + + +async def test_site_deduplication(single_site_rejoin_api: Mock) -> None: + """Test site deduplication.""" + filtered = filter_sites(single_site_rejoin_api.get_sites()) + assert len(filtered) == 2 + assert ( + next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE + ) + assert ( + next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED + ) diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 64fa39192a6..7808d1adcde 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections.abc import Generator +from datetime import date from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.model.channel import Channel, ChannelType from amberelectric.model.current_interval import CurrentInterval from amberelectric.model.interval import Descriptor, SpikeStatus -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus from dateutil import parser import pytest @@ -38,23 +39,35 @@ def mock_api_current_price() -> Generator: general_site = Site( GENERAL_ONLY_SITE_ID, "11111111111", - [Channel(identifier="E1", type=ChannelType.GENERAL)], + [Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) general_and_controlled_load = Site( GENERAL_AND_CONTROLLED_SITE_ID, "11111111112", [ - Channel(identifier="E1", type=ChannelType.GENERAL), - Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD), + Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), + Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD, tariff="A180"), ], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) general_and_feed_in = Site( GENERAL_AND_FEED_IN_SITE_ID, "11111111113", [ - Channel(identifier="E1", type=ChannelType.GENERAL), - Channel(identifier="E2", type=ChannelType.FEED_IN), + Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), + Channel(identifier="E2", type=ChannelType.FEED_IN, tariff="A100"), ], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) instance.get_sites.return_value = [ general_site, diff --git a/tests/components/analytics_insights/__init__.py b/tests/components/analytics_insights/__init__.py new file mode 100644 index 00000000000..9e20a72c438 --- /dev/null +++ b/tests/components/analytics_insights/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Homeassistant Analytics integration.""" +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/analytics_insights/conftest.py b/tests/components/analytics_insights/conftest.py new file mode 100644 index 00000000000..6ca98d294e6 --- /dev/null +++ b/tests/components/analytics_insights/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the Homeassistant Analytics tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_homeassistant_analytics import CurrentAnalytics +from python_homeassistant_analytics.models import CustomIntegration, Integration + +from homeassistant.components.analytics_insights import DOMAIN +from homeassistant.components.analytics_insights.const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, +) + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.analytics_insights.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_analytics_client() -> Generator[AsyncMock, None, None]: + """Mock a Homeassistant Analytics client.""" + with patch( + "homeassistant.components.analytics_insights.HomeassistantAnalyticsClient", + autospec=True, + ) as mock_client, patch( + "homeassistant.components.analytics_insights.config_flow.HomeassistantAnalyticsClient", + new=mock_client, + ): + client = mock_client.return_value + client.get_current_analytics.return_value = CurrentAnalytics.from_json( + load_fixture("analytics_insights/current_data.json") + ) + integrations = load_json_object_fixture("analytics_insights/integrations.json") + client.get_integrations.return_value = { + key: Integration.from_dict(value) for key, value in integrations.items() + } + custom_integrations = load_json_object_fixture( + "analytics_insights/custom_integrations.json" + ) + client.get_custom_integrations.return_value = { + key: CustomIntegration.from_dict(value) + for key, value in custom_integrations.items() + } + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Homeassistant Analytics", + data={}, + options={ + CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify", "myq"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ) diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json new file mode 100644 index 00000000000..c652a8c0154 --- /dev/null +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -0,0 +1,2516 @@ +{ + "last_updated": 1704885791095, + "countries": { + "US": 53255, + "DE": 43792, + "IN": 1135, + "CA": 8212, + "FI": 2859, + "IT": 11088, + "GB": 18165, + "SE": 8389, + "AU": 8996, + "PL": 8283, + "CH": 3845, + "BG": 798, + "NO": 3985, + "NL": 20162, + "CN": 13037, + "FR": 15643, + "RO": 2769, + "GR": 1255, + "GA": 4, + "HU": 2771, + "HK": 1777, + "UA": 2521, + "ES": 8712, + "CZ": 4694, + "BR": 4255, + "SK": 1212, + "RU": 9289, + "PT": 3334, + "ZA": 2642, + "BE": 6386, + "DK": 5059, + "CY": 181, + "AT": 4164, + "IL": 1132, + "IS": 262, + "LU": 281, + "AR": 842, + "BJ": 3, + "KR": 1323, + "PA": 76, + "ID": 772, + "IE": 1205, + "TW": 2187, + "SG": 961, + "BA": 49, + "TH": 1995, + "KZ": 227, + "VN": 998, + "HR": 518, + "BH": 53, + "CU": 18, + "BY": 507, + "MT": 109, + "MX": 1147, + "TR": 904, + "CO": 361, + "LT": 789, + "SI": 718, + "RE": 86, + "LB": 84, + "DO": 126, + "AZ": 36, + "NZ": 1692, + "SA": 274, + "AE": 310, + "JP": 901, + "LK": 61, + "EE": 693, + "HN": 31, + "EC": 117, + "CL": 615, + "LV": 450, + "MY": 639, + "UY": 167, + "BF": 4, + "QA": 47, + "PR": 120, + "SX": 3, + "NG": 80, + "BM": 10, + "MQ": 14, + "NA": 36, + "LY": 11, + "JM": 32, + "EG": 273, + "KY": 6, + "RS": 312, + "NP": 24, + "PK": 91, + "MD": 122, + "JE": 23, + "MK": 57, + "PE": 125, + "TT": 59, + "ZM": 10, + "PY": 60, + "PH": 348, + "IM": 38, + "LS": 4, + "ME": 30, + "TG": 15, + "IR": 66, + "GT": 30, + "MO": 55, + "IQ": 64, + "GI": 6, + "MZ": 15, + "GE": 77, + "CR": 161, + "MM": 24, + "TJ": 37, + "UZ": 85, + "AD": 10, + "AM": 73, + "PF": 12, + "CI": 16, + "KG": 30, + "BQ": 7, + "DZ": 58, + "GG": 17, + "BZ": 4, + "JO": 57, + "MV": 13, + "SV": 17, + "VE": 39, + "YE": 6, + "MA": 143, + "MU": 21, + "OM": 49, + "NC": 29, + "BO": 54, + "XK": 20, + "KW": 38, + "GU": 5, + "BS": 12, + "GP": 27, + "MN": 19, + "ET": 13, + "TN": 60, + "FO": 18, + "ZW": 20, + "KE": 40, + "LI": 17, + "BB": 15, + "KH": 38, + "CW": 26, + "BD": 73, + "SC": 3, + "SL": 1, + "SM": 12, + "GH": 52, + "PS": 20, + "PG": 3, + "AO": 11, + "FJ": 4, + "AX": 17, + "NI": 8, + "AW": 17, + "GD": 2, + "SN": 13, + "LA": 14, + "MG": 8, + "AL": 17, + "GF": 6, + "CG": 2, + "GY": 4, + "SR": 17, + "TC": 1, + "UG": 12, + "GL": 2, + "VC": 3, + "IO": 1, + "TZ": 8, + "RW": 3, + "CV": 7, + "LC": 8, + "YT": 2, + "ML": 2, + "AG": 7, + "MF": 1, + "BN": 12, + "HT": 3, + "VI": 2, + "MW": 4, + "BT": 3, + "WS": 1, + "VG": 3, + "SY": 2, + "BW": 8, + "CM": 4, + "DJ": 1, + "TL": 1, + "SS": 1, + "TM": 1, + "MC": 2, + "T1": 1, + "AI": 1, + "FM": 1, + "CD": 2 + }, + "installation_types": { + "os": 223469, + "container": 52434, + "core": 9752, + "supervised": 15221, + "unsupported_container": 6100, + "unknown": 3424 + }, + "active_installations": 310400, + "avg_users": 1, + "avg_automations": 12, + "avg_integrations": 27, + "avg_addons": 6, + "avg_states": 208, + "integrations": { + "script": 246128, + "cpuspeed": 6523, + "apple_tv": 31246, + "hue": 39499, + "radio_browser": 176070, + "scene": 242257, + "dlna_dmr": 68815, + "google_translate": 129024, + "frontend": 204291, + "knx": 2931, + "hassio": 193035, + "cast": 124532, + "webostv": 28423, + "thread": 56573, + "met": 193200, + "person": 248256, + "openai_conversation": 5151, + "androidtv_remote": 42649, + "sun": 247811, + "mobile_app": 202779, + "default_config": 242787, + "automation": 247261, + "homekit": 46540, + "google": 17436, + "homekit_controller": 32230, + "tuya": 48547, + "xbox": 6322, + "shopping_list": 96171, + "cloud": 88674, + "zone": 90365, + "group": 88150, + "generic": 17347, + "timer": 49307, + "persistent_notification": 27681, + "notify": 58782, + "diagnostics": 24576, + "auth": 27797, + "raspberry_pi": 5212, + "button": 16379, + "recorder": 61879, + "energy": 74107, + "stream": 32220, + "zeroconf": 30200, + "dhcp": 28169, + "input_number": 67186, + "hikvision": 1167, + "samsungtv": 40354, + "lock": 12257, + "repairs": 21514, + "device_automation": 27672, + "websocket_api": 28669, + "siren": 10473, + "input_text": 51877, + "nest": 12686, + "media_player": 32265, + "schedule": 32924, + "number": 15660, + "logger": 41301, + "hardware": 21352, + "stt": 8616, + "image_upload": 16394, + "file_upload": 20736, + "onboarding": 27672, + "search": 27672, + "input_select": 55495, + "conversation": 18537, + "input_button": 45918, + "usb": 27664, + "map": 31265, + "camera": 29502, + "cover": 22675, + "vacuum": 11051, + "ipp": 68927, + "ffmpeg": 21083, + "blueprint": 27578, + "tplink": 23668, + "update": 15896, + "assist_pipeline": 7784, + "counter": 39800, + "weather": 23585, + "input_datetime": 55333, + "media_source": 30841, + "input_boolean": 87498, + "binary_sensor": 50815, + "humidifier": 10005, + "tag": 28172, + "system_log": 29282, + "network": 27897, + "ssdp": 29607, + "alarm_control_panel": 20449, + "system_health": 33296, + "application_credentials": 22649, + "bluetooth_adapters": 8357, + "device_tracker": 33009, + "zwave_js": 24553, + "http": 89834, + "history": 35110, + "climate": 31824, + "sensor": 101798, + "my": 32475, + "light": 36609, + "homeassistant": 108327, + "webhook": 28963, + "trace": 27575, + "bluetooth": 124139, + "fan": 17378, + "upnp": 84171, + "logbook": 35151, + "rpi_power": 86861, + "config": 33096, + "analytics": 27699, + "homeassistant_alerts": 22022, + "lovelace": 37644, + "remote": 8677, + "switch": 50974, + "select": 14321, + "api": 32847, + "google_assistant": 14358, + "tts": 140044, + "co2signal": 24353, + "ibeacon": 46067, + "dlna_dms": 55732, + "accuweather": 11610, + "forecast_solar": 25140, + "sharkiq": 1272, + "derivative": 4282, + "google_assistant_sdk": 5727, + "wyoming": 13699, + "oralb": 7812, + "rhasspy": 1020, + "braviatv": 9300, + "utility_meter": 27735, + "shell_command": 10702, + "ecobee": 7789, + "govee_ble": 3648, + "lifx": 4829, + "sma": 1717, + "esphome": 56656, + "wake_on_lan": 19186, + "ovo_energy": 272, + "generic_thermostat": 3333, + "asuswrt": 2656, + "sonos": 36389, + "homeassistant_sky_connect": 13911, + "local_calendar": 17171, + "nfandroidtv": 2838, + "workday": 12748, + "trafikverket_weatherstation": 440, + "jellyfin": 2079, + "inkbird": 1768, + "nut": 8750, + "mqtt": 108113, + "min_max": 14592, + "smhi": 1917, + "zha": 58308, + "roborock": 7363, + "template": 50756, + "python_script": 7050, + "synology_dsm": 26998, + "shelly": 44653, + "imap": 789, + "spotify": 24388, + "enphase_envoy": 2833, + "ld2410_ble": 935, + "command_line": 10749, + "mjpeg": 6094, + "open_meteo": 2590, + "intent_script": 1526, + "local_todo": 8378, + "ping": 13284, + "deconz": 5495, + "telegram_bot": 14008, + "systemmonitor": 32242, + "fritzbox": 14039, + "fully_kiosk": 6518, + "fritz": 18266, + "androidtv": 8058, + "brother": 19543, + "xiaomi_miio": 16242, + "influxdb": 21696, + "home_connect": 5784, + "edl21": 288, + "text": 5868, + "tasmota": 26322, + "onvif": 9899, + "blink": 5458, + "time_date": 1168, + "panel_custom": 7196, + "wiz": 8421, + "plex": 13197, + "pi_hole": 7098, + "devolo_home_network": 1472, + "syncthru": 2507, + "ring": 13663, + "tado": 8639, + "broadlink": 18804, + "yeelight": 9919, + "goodwe": 1302, + "integration": 13884, + "wled": 17444, + "prometheus": 1447, + "reolink": 12919, + "water_heater": 3028, + "switch_as_x": 36720, + "mvglive": 1, + "coronavirus": 256, + "openweathermap": 21362, + "calendar": 4770, + "smartthings": 10651, + "panel_iframe": 7752, + "version": 8944, + "adguard": 13192, + "unifi": 16589, + "xiaomi_aqara": 2887, + "unifiprotect": 10382, + "speedtestdotnet": 23310, + "twilio": 984, + "radarr": 1962, + "twinkly": 3075, + "sonarr": 2555, + "cloudflare": 1916, + "matter": 17099, + "fronius": 3296, + "doorbird": 979, + "dsmr": 4086, + "moon": 12149, + "roku": 10092, + "airnow": 799, + "abode": 531, + "flux_led": 5932, + "bond": 2105, + "opower": 1282, + "yolink": 990, + "philips_js": 3285, + "cert_expiry": 4618, + "harmony": 10081, + "alexa": 7705, + "rfxtrx": 2359, + "vesync": 3161, + "wemo": 7182, + "ios": 12512, + "xiaomi_ble": 15798, + "lutron_caseta": 5293, + "qingping": 698, + "profiler": 1696, + "ifttt": 7056, + "youtube": 339, + "vizio": 2630, + "kodi": 5174, + "foscam": 1652, + "overkiz": 4492, + "rest_command": 10337, + "yamaha_musiccast": 4436, + "tibber": 5121, + "surepetcare": 838, + "whirlpool": 231, + "freebox": 2558, + "tile": 3222, + "netatmo": 11034, + "github": 3137, + "meteo_france": 3916, + "homewizard": 5398, + "elgato": 1956, + "lastfm": 302, + "history_stats": 865, + "owntracks": 2356, + "backup": 11951, + "aussie_broadband": 506, + "image": 11833, + "buienradar": 6651, + "sql": 4960, + "mikrotik": 1761, + "local_ip": 15081, + "uptime": 12332, + "otbr": 5596, + "octoprint": 9560, + "aemet": 1390, + "melcloud": 2786, + "heos": 6197, + "denonavr": 10899, + "image_processing": 1319, + "panasonic_viera": 1191, + "lacrosse_view": 181, + "starlink": 444, + "google_mail": 762, + "tailscale": 737, + "icloud": 5767, + "nmap_tracker": 5171, + "dnsip": 4586, + "konnected": 1305, + "tesla_wall_connector": 2325, + "roomba": 8552, + "decora_wifi": 622, + "ambient_station": 1249, + "life360": 2360, + "scrape": 2436, + "threshold": 7052, + "august": 4752, + "proximity": 4814, + "srp_energy": 82, + "dialogflow": 623, + "songpal": 1483, + "soundtouch": 2080, + "opnsense": 322, + "lg_soundbar": 362, + "statistics": 2149, + "frontier_silicon": 1612, + "mazda": 66, + "daikin": 3030, + "emulated_hue": 2537, + "tautulli": 1287, + "syncthing": 448, + "amcrest": 1324, + "motioneye": 3538, + "directv": 312, + "rainbird": 704, + "solaredge": 4777, + "filesize": 2163, + "flume": 848, + "discord": 1478, + "sense": 1635, + "waze_travel_time": 5655, + "season": 6327, + "tod": 4741, + "verisure": 1468, + "energyzero": 715, + "hunterdouglas_powerview": 519, + "plant": 1728, + "qnap": 1871, + "metoffice": 1787, + "hdmi_cec": 67, + "sensibo": 2862, + "adax": 473, + "netgear": 2291, + "iss": 1718, + "vlc_telnet": 4141, + "kostal_plenticore": 635, + "solax": 159, + "prusalink": 744, + "econet": 761, + "coinbase": 414, + "hive": 1436, + "bmw_connected_drive": 2445, + "compensation": 171, + "nina": 2515, + "tractive": 688, + "tankerkoenig": 2261, + "nanoleaf": 3266, + "tomorrowio": 1651, + "slimproto": 338, + "squeezebox": 1398, + "schlage": 479, + "switcher_kis": 265, + "switchbot": 6961, + "switchbot_cloud": 1020, + "bayesian": 195, + "pushover": 2445, + "keenetic_ndms2": 1945, + "ourgroceries": 469, + "vicare": 1495, + "thermopro": 639, + "neato": 935, + "roon": 405, + "renault": 1287, + "bthome": 4166, + "nuki": 1974, + "modbus": 4746, + "telegram": 660, + "updater": 3559, + "aladdin_connect": 772, + "deluge": 245, + "opensky": 404, + "airzone_cloud": 120, + "ecovacs": 765, + "nws": 4082, + "alert": 2209, + "media_extractor": 1489, + "openuv": 1917, + "iqvia": 567, + "tradfri": 5548, + "velux": 291, + "growatt_server": 842, + "pvoutput": 441, + "intent": 1357, + "withings": 2143, + "soma": 95, + "ps4": 1727, + "lametric": 680, + "google_sheets": 1171, + "downloader": 1259, + "rest": 7444, + "powerwall": 1163, + "nissan_leaf": 296, + "wallbox": 885, + "glances": 2791, + "hyperion": 1487, + "dwd_weather_warnings": 2788, + "airvisual": 1363, + "dsmr_reader": 989, + "apcupsd": 871, + "sleepiq": 956, + "sabnzbd": 1023, + "steam_online": 1547, + "tellduslive": 864, + "airthings_ble": 1018, + "tellstick": 230, + "airthings": 1308, + "mill": 892, + "yalexs_ble": 953, + "environment_canada": 1702, + "homeassistant_hardware": 310, + "smtp": 233, + "thermobeacon": 614, + "eufylife_ble": 640, + "evohome": 1266, + "meater": 1302, + "notion": 134, + "fibaro": 660, + "fastdotcom": 1076, + "volvooncall": 1228, + "seventeentrack": 854, + "voip": 1332, + "lidarr": 312, + "onewire": 257, + "joaoapps_join": 177, + "zodiac": 496, + "aurora": 977, + "transmission": 1793, + "emulated_roku": 1110, + "asterisk_mbox": 26, + "rtsp_to_webrtc": 2378, + "feedreader": 729, + "google_tasks": 655, + "here_travel_time": 354, + "minecraft_server": 1106, + "gdacs": 1234, + "snmp": 345, + "gpslogger": 618, + "rachio": 3084, + "iaqualink": 328, + "kef": 51, + "fireservicerota": 34, + "gios": 280, + "mysensors": 481, + "airly": 774, + "ezviz": 2849, + "opentherm_gw": 230, + "picnic": 698, + "traccar": 556, + "bluetooth_tracker": 98, + "recollect_waste": 124, + "mqtt_room": 465, + "snapcast": 353, + "incomfort": 106, + "browser": 557, + "rpi_camera": 86, + "qbittorrent": 1307, + "nexia": 475, + "modern_forms": 340, + "fritzbox_callmonitor": 1945, + "google_travel_time": 820, + "discovery": 602, + "bosch_shc": 587, + "gree": 2372, + "waqi": 1539, + "mqtt_json": 5, + "caldav": 515, + "mqtt_statestream": 573, + "islamic_prayer_times": 523, + "todoist": 1150, + "hardkernel": 357, + "honeywell": 1736, + "ruuvitag_ble": 743, + "android_ip_webcam": 920, + "aranet": 308, + "color_extractor": 434, + "youless": 321, + "smappee": 253, + "subaru": 265, + "ukraine_alarm": 555, + "rainforest_eagle": 254, + "homematicip_cloud": 2030, + "pvpc_hourly_pricing": 1229, + "sia": 478, + "mediaroom": 36, + "iotawatt": 446, + "ipma": 636, + "trend": 207, + "uptimerobot": 933, + "huawei_lte": 646, + "mailgun": 106, + "duckdns": 1942, + "onkyo": 145, + "folder_watcher": 651, + "private_ble_device": 414, + "zwave_me": 64, + "somfy": 32, + "slack": 521, + "debugpy": 104, + "vera": 352, + "led_ble": 642, + "pushbullet": 1096, + "rflink": 835, + "weatherflow": 441, + "slide": 109, + "litterrobot": 1349, + "smart_meter_texas": 241, + "stookalert": 443, + "proxmoxve": 1348, + "motion_blinds": 628, + "lupusec": 29, + "ambiclimate": 52, + "keyboard_remote": 77, + "habitica": 94, + "zamg": 443, + "enigma2": 485, + "geo_location": 657, + "universal": 554, + "mystrom": 601, + "holiday": 1155, + "livisi": 231, + "tessie": 122, + "awair": 1025, + "microsoft_face": 1, + "zoneminder": 256, + "nibe_heatpump": 285, + "envisalink": 853, + "danfoss_air": 30, + "ihc": 257, + "nextdns": 500, + "homematic": 1187, + "lyric": 713, + "comfoconnect": 157, + "arris_tg2492lg": 2, + "nextcloud": 1136, + "hydrawise": 588, + "baf": 399, + "manual": 353, + "volumio": 1412, + "blebox": 287, + "mold_indicator": 24, + "remote_rpi_gpio": 36, + "wolflink": 179, + "modem_callerid": 24, + "rdw": 772, + "hisense_aehw4a1": 38, + "velbus": 79, + "yale_smart_alarm": 350, + "sfr_box": 174, + "luftdaten": 735, + "wiffi": 267, + "agent_dvr": 706, + "starline": 299, + "ecoforest": 10, + "nsw_fuel_station": 98, + "nzbget": 320, + "epson": 215, + "ecowitt": 3008, + "alarmdecoder": 118, + "snips": 27, + "notify_events": 423, + "jewish_calendar": 217, + "flux": 30, + "stookwijzer": 191, + "openexchangerates": 326, + "simplisafe": 804, + "trafikverket_camera": 110, + "p1_monitor": 338, + "air_quality": 386, + "totalconnect": 201, + "launch_library": 279, + "random": 180, + "no_ip": 236, + "nuheat": 166, + "tilt_ble": 84, + "satel_integra": 384, + "risco": 276, + "qnap_qsw": 52, + "kraken": 300, + "emby": 595, + "geofency": 313, + "hvv_departures": 70, + "devolo_home_control": 65, + "vulcan": 24, + "laundrify": 151, + "openhome": 730, + "rainmachine": 381, + "sms": 162, + "schluter": 92, + "sensorpro": 35, + "pegel_online": 219, + "worldclock": 30, + "rss_feed_template": 29, + "emulated_kasa": 116, + "obihai": 448, + "filter": 735, + "remember_the_milk": 35, + "tplink_omada": 1033, + "isy994": 415, + "mullvad": 121, + "rapt_ble": 32, + "eufy": 101, + "eafm": 352, + "meteoclimatic": 158, + "prosegur": 92, + "insteon": 1086, + "flipr": 67, + "intesishome": 263, + "twentemilieu": 197, + "brottsplatskartan": 170, + "kitchen_sink": 3, + "demo": 79, + "balboa": 90, + "saj": 76, + "rpi_gpio": 42, + "advantage_air": 266, + "emoncms": 15, + "synology_srm": 7, + "twilio_call": 18, + "intellifire": 34, + "opengarage": 425, + "luci": 36, + "aurora_abb_powerone": 34, + "sensorpush": 104, + "iperf3": 136, + "mutesync": 61, + "google_generative_ai_conversation": 199, + "yamaha": 135, + "solarlog": 177, + "radiotherm": 104, + "easyenergy": 140, + "plugwise": 542, + "aqualogic": 17, + "google_wifi": 43, + "dexcom": 338, + "arcam_fmj": 57, + "gogogate2": 407, + "serial": 21, + "thingspeak": 43, + "watttime": 15, + "axis": 927, + "mpd": 82, + "locative": 179, + "mopeka": 225, + "fitbit": 619, + "pilight": 10, + "anova": 80, + "control4": 137, + "electric_kiwi": 20, + "geonetnz_quakes": 157, + "amberelectric": 336, + "efergy": 286, + "coolmaster": 60, + "mitemp_bt": 24, + "omnilogic": 86, + "tmb": 10, + "trafikverket_train": 77, + "simplepush": 77, + "garmin_connect": 3, + "homeassistant_yellow": 114, + "clicksend": 7, + "flo": 412, + "supla": 109, + "netgear_lte": 64, + "forked_daapd": 293, + "safe_mode": 81, + "airzone": 141, + "somfy_mylink": 108, + "airvisual_pro": 132, + "nam": 58, + "geocaching": 328, + "electrasmart": 25, + "loqed": 211, + "twitch": 127, + "toon": 253, + "system_bridge": 214, + "recswitch": 21, + "met_eireann": 303, + "ziggo_mediabox_xl": 3, + "swiss_public_transport": 123, + "purpleair": 87, + "freedns": 50, + "venstar": 210, + "lightwave": 103, + "kaiterra": 51, + "graphite": 13, + "bluesound": 313, + "ondilo_ico": 153, + "atag": 87, + "generic_hygrostat": 763, + "screenlogic": 309, + "ws66i": 12, + "rituals_perfume_genie": 351, + "maxcube": 200, + "senseme": 9, + "elkm1": 239, + "vallox": 158, + "bsblan": 28, + "google_cloud": 19, + "darksky": 79, + "peco": 57, + "ruckus_unleashed": 97, + "splunk": 54, + "local_file": 71, + "file": 90, + "configurator": 114, + "snooz": 53, + "vodafone_station": 76, + "faa_delays": 143, + "dht": 6, + "egardia": 33, + "oncue": 73, + "tailwind": 44, + "dunehd": 102, + "flick_electric": 42, + "home_plus_control": 85, + "weishaupt_wcm_com": 1, + "sentry": 63, + "linux_battery": 6, + "nmbs": 48, + "linode": 22, + "aseko_pool_live": 7, + "senz": 90, + "whois": 365, + "ness_alarm": 40, + "google_pubsub": 23, + "worxlandroid": 14, + "goalzero": 38, + "canary": 108, + "dlink": 175, + "skybell": 68, + "moat": 4, + "dynalite": 37, + "namecheapdns": 89, + "flic": 131, + "datadog": 22, + "google_maps": 11, + "spc": 46, + "wirelesstag": 132, + "dormakaba_dkey": 8, + "microsoft": 11, + "digital_ocean": 43, + "marytts": 4, + "geniushub": 25, + "lutron": 116, + "keba": 91, + "mqtt_eventstream": 96, + "zeversolar": 88, + "fjaraskupan": 28, + "juicenet": 194, + "niko_home_control": 11, + "ads": 37, + "zabbix": 50, + "eight_sleep": 56, + "nobo_hub": 82, + "flexit_bacnet": 14, + "doods": 18, + "matrix": 213, + "mfi": 17, + "brunt": 39, + "device_sun_light_trigger": 77, + "ohmconnect": 96, + "folder": 19, + "alpha_vantage": 7, + "edimax": 14, + "lcn": 38, + "zwave": 33, + "airq": 51, + "flunearyou": 9, + "moehlenhoff_alpha2": 40, + "aftership": 60, + "thethingsnetwork": 38, + "escea": 17, + "trafikverket_ferry": 12, + "firmata": 84, + "lookin": 33, + "izone": 91, + "enocean": 219, + "geonetnz_volcano": 41, + "miflora": 15, + "airtouch4": 90, + "v2c": 32, + "ruuvi_gateway": 72, + "html5": 58, + "spider": 22, + "repetier": 35, + "idasen_desk": 125, + "melissa": 13, + "xiaomi_tv": 19, + "google_domains": 110, + "geo_json_events": 44, + "pocketcasts": 5, + "landisgyr_heat_meter": 14, + "waterfurnace": 15, + "devialet": 81, + "bluemaestro": 19, + "mochad": 25, + "mailbox": 3, + "acmeda": 29, + "anthemav": 99, + "point": 25, + "lg_netcast": 15, + "lifx_cloud": 4, + "bluetooth_le_tracker": 27, + "uvc": 46, + "ombi": 67, + "mycroft": 5, + "streamlabswater": 18, + "azure_service_bus": 2, + "reddit": 2, + "fixer": 2, + "pure_energie": 59, + "iammeter": 94, + "hlk_sw16": 14, + "foobot": 34, + "bloomsky": 6, + "smarty": 1, + "ialarm": 27, + "time": 14, + "touchline": 3, + "signal_messenger": 10, + "vlc": 7, + "ebusd": 43, + "jvc_projector": 29, + "starlingbank": 3, + "weatherkit": 95, + "openhardwaremonitor": 20, + "rympro": 21, + "gardena_bluetooth": 44, + "swiss_hydrological_data": 69, + "usgs_earthquakes_feed": 9, + "melnor": 42, + "plaato": 45, + "freedompro": 26, + "sunweg": 3, + "logi_circle": 18, + "proxy": 16, + "statsd": 4, + "baidu": 35, + "sensirion_ble": 52, + "manual_mqtt": 11, + "aws": 60, + "qvr_pro": 44, + "amazon_polly": 16, + "duotecno": 5, + "huisbaasje": 43, + "suez_water": 30, + "ridwell": 33, + "switchbee": 3, + "nightscout": 172, + "almond": 29, + "bitcoin": 25, + "smarttub": 37, + "foursquare": 7, + "aosmith": 14, + "discovergy": 91, + "w800rf32": 6, + "opple": 15, + "route53": 72, + "pioneer": 11, + "linksys_smart": 3, + "fivem": 19, + "synology_chat": 4, + "nederlandse_spoorwegen": 3, + "emoncms_history": 48, + "osramlightify": 58, + "renson": 8, + "monoprice": 114, + "ffmpeg_motion": 13, + "zestimate": 16, + "qwikswitch": 9, + "meteoalarm": 8, + "denon": 6, + "kaleidescape": 20, + "tomato": 7, + "azure_event_hub": 20, + "ozw": 7, + "panasonic_bluray": 3, + "keymitt_ble": 9, + "tcp": 10, + "kmtronic": 21, + "syslog": 8, + "picotts": 21, + "twilio_sms": 29, + "upb": 32, + "hp_ilo": 12, + "minio": 7, + "quantum_gateway": 5, + "itunes": 7, + "nsw_rural_fire_service_feed": 2, + "transport_nsw": 4, + "x10": 2, + "familyhub": 13, + "switchmate": 37, + "harman_kardon_avr": 3, + "pjlink": 6, + "kegtron": 5, + "azure_devops": 23, + "beewi_smartclim": 1, + "poolsense": 22, + "meraki": 4, + "dte_energy_bridge": 2, + "apache_kafka": 1, + "todo": 5, + "torque": 5, + "vilfo": 2, + "itach": 3, + "msteams": 13, + "sisyphus": 22, + "hikvisioncam": 18, + "tami4": 16, + "london_underground": 3, + "sendgrid": 4, + "emonitor": 5, + "free_mobile": 9, + "limitlessled": 22, + "entur_public_transport": 7, + "linear_garage_door": 2, + "event": 4, + "voicerss": 3, + "comapsmarthome": 2, + "yardian": 13, + "plum_lightpad": 1, + "xiaomi": 14, + "bt_smarthub": 4, + "imap_email_content": 14, + "prowl": 9, + "russound_rio": 3, + "serial_pm": 2, + "raspyrfm": 1, + "oru": 4, + "sesame": 5, + "watson_tts": 7, + "sky_hub": 2, + "tolo": 3, + "ffmpeg_noise": 9, + "kira": 8, + "aprs": 5, + "otp": 2, + "gtfs": 2, + "stiebel_eltron": 19, + "osoenergy": 10, + "orvibo": 14, + "homeworks": 8, + "lannouncer": 7, + "guardian": 9, + "rmvtransport": 2, + "greeneye_monitor": 6, + "openevse": 1, + "nextbus": 16, + "clickatell": 2, + "gc100": 4, + "facebook": 3, + "progettihwsw": 2, + "warnwetter": 2, + "unifiled": 1, + "telnet": 9, + "ted5000": 2, + "dremel_3d_printer": 11, + "neurio_energy": 1, + "ddwrt": 7, + "climacell": 2, + "lirc": 3, + "refoss": 2, + "nad": 2, + "raincloud": 3, + "aquostv": 4, + "auth_header": 3, + "sharpai": 3, + "channels": 3, + "yandex_transport": 2, + "mastodon": 1, + "comelit": 3, + "sighthound": 2, + "eq3btsmart": 5, + "message_bird": 4, + "pyload": 1, + "citybikes": 2, + "comed_hourly_pricing": 4, + "xmpp": 3, + "worldtidesinfo": 2, + "ccm15": 2, + "netdata": 3, + "fritzbox_netmonitor": 4, + "apprise": 2, + "drop_connect": 1, + "permobil": 3, + "norway_air": 3, + "push": 2, + "upc_connect": 2, + "ecoal_boiler": 3, + "garages_amsterdam": 10, + "elmax": 6, + "unifi_direct": 5, + "tesla": 6, + "atome": 2, + "london_air": 1, + "anel_pwrctrl": 5, + "pushsafer": 2, + "supervisord": 2, + "hangouts": 4, + "crownstone": 6, + "noaa_tides": 6, + "delijn": 1, + "numato": 1, + "evil_genius_labs": 4, + "temper": 4, + "ign_sismologia": 2, + "yi": 3, + "xs1": 4, + "datetime": 2, + "opensensemap": 4, + "sinch": 2, + "logentries": 1, + "dominos": 2, + "shodan": 1, + "ephember": 3, + "mcp23017": 1, + "currencylayer": 2, + "music_light_switch": 1, + "weather_light_switch": 1, + "fortios": 1, + "clicksend_tts": 1, + "deutsche_bahn": 3, + "geo_rss_events": 2, + "rejseplanen": 4, + "wilight": 3, + "lacrosse": 3, + "aruba": 1, + "yeelightsunflower": 2, + "hddtemp": 2, + "volkszaehler": 1, + "llamalab_automate": 1, + "viaggiatreno": 1, + "valve": 1, + "smartthings_soundbar": 1, + "samsungtv_smart": 1, + "gardena_smart_system": 1, + "sonoff": 1, + "sony_projector": 3, + "facebox": 1, + "bbox": 2, + "twitter": 4, + "asustor": 1, + "vivotek": 1, + "mythicbeastsdns": 2, + "dweet": 1, + "vasttrafik": 1, + "uk_transport": 4, + "scsgate": 1, + "veea": 1, + "dovado": 1, + "garadget": 2, + "simulated": 1, + "gpsd": 1, + "wsdot": 1, + "swisscom": 1, + "bme280": 1, + "spaceapi": 3, + "nx584": 1, + "discogs": 1, + "fail2ban": 1, + "futurenow": 1, + "idteck_prox": 1, + "seven_segments": 3, + "qld_bushfire": 1, + "cups": 2, + "arest": 1, + "eliqonline": 1, + "yandextts": 2, + "blockchain": 1, + "date": 1, + "elv": 1, + "tank_utility": 1, + "etherscan": 1, + "blue_current": 1, + "tplink_lte": 1, + "rpi_rf": 1 + }, + "operating_system": { + "boards": { + "ova": 62036, + "rpi3-64": 17386, + "rpi4-64": 61880, + "rpi4": 1476, + "generic-x86-64": 27962, + "yellow": 4767, + "odroid-xu4": 191, + "green": 3671, + "rpi3": 2016, + "odroid-n2": 4919, + "rpi2": 516, + "odroid-c2": 122, + "generic-aarch64": 1088, + "odroid-m1": 195, + "tinker": 111, + "rpi5-64": 686, + "khadas-vim3": 53, + "odroid-c4": 227 + }, + "versions": { + "10.5": 9182, + "11.3": 50875, + "11.2": 53976, + "10.2": 1131, + "11.4": 26448, + "11.1": 25489, + "10.4": 2017, + "9.3": 991, + "10.3": 3390, + "11.0": 3494, + "9.5": 3518, + "11.4.rc1": 313, + "11.3.rc1": 405, + "9.4": 1314, + "10.0": 337, + "11.0.rc1": 12, + "9.0": 293, + "10.1": 1631, + "8.4": 258, + "7.0": 124, + "9.2": 255, + "8.5": 629, + "11.2.rc1": 86, + "8.2": 373, + "7.6": 540, + "6.4": 110, + "7.1": 117, + "6.6": 253, + "6.3": 35, + "5.13": 85, + "6.2": 97, + "6.0": 31, + "11.3.dev20231221": 39, + "8.3": 11, + "7.4": 264, + "11.4.dev20231223": 45, + "11.3.dev20231212": 41, + "11.4.dev20240107": 13, + "8.1": 180, + "11.3.dev20231214": 47, + "7.3": 27, + "11.0.dev20230926": 3, + "6.5": 64, + "11.2.rc2": 83, + "11.3.rc2": 119, + "8.0": 32, + "6.1": 67, + "7.2": 96, + "11.4.dev20240108": 44, + "8.0.rc3": 1, + "11.2.dev20231120": 3, + "11.4.dev20231226": 87, + "10.0.rc3": 10, + "7.5": 99, + "9.0.rc1": 3, + "11.3.dev20231220": 10, + "11.4.dev20240105": 14, + "8.0.dev20220126": 1, + "7.0.rc1": 3, + "8.0.rc2": 4, + "10.0.rc2": 4, + "11.2.dev20231116": 3, + "11.1.rc1": 8, + "10.0.dev20220912": 1, + "5.11": 2, + "11.3.dev20231201": 12, + "11.0.dev20230511": 2, + "11.0.dev20230609": 1, + "4.20": 2, + "11.0.dev20230921": 2, + "11.2.dev20231102": 2, + "11.0.dev20230627": 1, + "11.3.dev20231211": 3, + "5.12": 7, + "11.0.dev20231004": 1, + "10.0.dev20221130": 1, + "11.2.dev20231109": 3, + "9.0.rc2": 6, + "11.0.dev20230705": 1, + "11.0.dev20230524": 1, + "11.0.dev20230601": 1, + "5.10": 1, + "11.2.dev20231106": 1, + "8.0.dev20220505": 1, + "11.0.rc2": 2, + "11.0.dev20230720": 1, + "4.12": 1, + "11.0.dev20230622": 1, + "9.1": 1, + "10.0.dev20221110": 1, + "11.3.dev0": 1, + "11.0.dev20230416": 1, + "8.0.rc4": 1, + "10.0.rc1": 1, + "11.0.dev20230413": 1, + "10.0.dev20221222": 1, + "11.2.dev20231031": 1, + "11.1.dev20231011": 1, + "11.0.dev20230823": 1 + } + }, + "supervisor": { + "arch": { + "amd64": 96508, + "aarch64": 100709, + "armv7": 4920, + "armhf": 375, + "i386": 20 + }, + "unhealthy": 3486, + "unsupported": 9329 + }, + "reports_integrations": 249256, + "reports_addons": 174735, + "reports_statistics": 241738, + "versions": { + "2023.5.4": 1615, + "2024.1.2": 97867, + "2023.9.1": 1000, + "2023.12.4": 29374, + "2022.8.6": 238, + "2023.11.3": 23970, + "2023.5.3": 979, + "2024.1.1": 6877, + "2023.11.1": 3990, + "2023.6.2": 1001, + "2023.12.3": 28121, + "2023.10.0": 726, + "2023.8.4": 2548, + "2023.12.1": 10408, + "2023.2.5": 1220, + "2023.1.7": 1346, + "2024.1.0": 12599, + "2023.10.1": 2179, + "2022.12.8": 904, + "2023.3.6": 1220, + "2023.8.0": 667, + "2023.7.0": 161, + "2022.12.6": 202, + "2023.3.3": 502, + "2023.10.4": 300, + "2024.2.0.dev20240105": 8, + "2023.6.1": 779, + "2023.11.2": 15694, + "2022.11.3": 216, + "2023.10.5": 5379, + "2023.12.0": 4192, + "2023.9.2": 3728, + "2023.7.3": 3932, + "2023.6.3": 2790, + "2023.4.2": 399, + "2023.12.2": 2328, + "2022.4.7": 184, + "2023.10.3": 4071, + "2023.9.3": 3649, + "2023.2.1": 189, + "2023.8.2": 1219, + "2023.3.1": 516, + "2023.6.0": 167, + "2021.4.3": 39, + "2022.10.4": 250, + "2023.3.5": 524, + "2023.5.2": 947, + "2023.4.5": 357, + "2023.8.3": 1211, + "2022.10.5": 677, + "2021.4.6": 89, + "2023.4.6": 1336, + "2023.3.4": 243, + "2022.12.9": 142, + "2022.3.5": 148, + "2023.4.4": 429, + "2022.8.7": 383, + "2021.12.3": 76, + "2023.1.0.dev20221210": 1, + "2023.8.1": 1298, + "2023.4.1": 271, + "2024.1.0.dev20231223": 14, + "2022.6.0.dev20220502": 1, + "2021.12.9": 156, + "2022.8.4": 80, + "2022.11.5": 434, + "2021.11.5": 397, + "2023.11.0": 1914, + "2024.1.0b2": 68, + "2023.9.0": 502, + "2023.1.6": 224, + "2023.1.2": 265, + "2022.4.5": 75, + "2022.11.2": 449, + "2021.6.6": 1306, + "2023.7.1": 1090, + "2022.11.4": 559, + "2021.11.1": 67, + "2022.4.0": 34, + "2023.2.3": 461, + "2023.7.2": 1224, + "2023.2.2": 243, + "2022.8.5": 53, + "2021.12.8": 436, + "2023.1.0": 110, + "2022.2.9": 273, + "2021.8.8": 174, + "2022.9.4": 162, + "2022.10.1": 117, + "2023.7.0.dev20230626": 233, + "2023.1.1": 271, + "2022.7.6": 111, + "2022.12.3": 73, + "2022.12.0": 101, + "2022.8.2": 62, + "2021.12.7": 131, + "2022.7.2": 56, + "2021.8.5": 7, + "2022.9.7": 344, + "2022.5.2": 34, + "2022.10.0": 50, + "2021.9.7": 433, + "2022.7.3": 69, + "2021.12.6": 34, + "2021.12.2": 40, + "2024.1.0b1": 9, + "2021.12.10": 429, + "2023.1.4": 319, + "2021.4.4": 27, + "2022.9.6": 191, + "2022.6.7": 379, + "2022.7.7": 189, + "2022.5.1": 21, + "2022.5.4": 132, + "2022.9.0": 59, + "2022.5.3": 87, + "2022.11.1": 255, + "2022.2.5": 45, + "2021.10.6": 163, + "2022.3.8": 164, + "2024.1.0b0": 26, + "2022.7.5": 158, + "2022.3.7": 106, + "2022.8.1": 63, + "2021.8.4": 20, + "2022.8.0": 37, + "2021.12.5": 89, + "2023.3.0": 83, + "2022.9.2": 67, + "2024.1.0.dev20231222": 7, + "2021.6.4": 22, + "2023.5.0.dev20230413": 1, + "2021.5.1": 23, + "2022.9.5": 87, + "2022.6.3": 28, + "2021.9.4": 17, + "2022.12.1": 191, + "2023.2.0b9": 2, + "2022.12.7": 228, + "2023.10.2": 316, + "2022.4.4": 39, + "2022.3.6": 29, + "2023.12.0.dev20231122": 5, + "2022.5.5": 250, + "2023.2.0": 116, + "2022.2.0": 25, + "2021.9.5": 23, + "2023.2.4": 165, + "2022.10.3": 154, + "2022.4.3": 29, + "2021.10.7": 35, + "2023.4.3": 54, + "2022.12.4": 40, + "2021.10.5": 23, + "2023.5.0": 99, + "2022.3.1": 68, + "2022.6.6": 175, + "2022.2.2": 49, + "2023.8.0.dev20230723": 123, + "2021.8.7": 28, + "2022.7.4": 33, + "2023.5.1": 91, + "2024.1.0.dev20231212": 15, + "2024.2.0.dev20240109": 23, + "2024.1.0.dev20231215": 7, + "2023.11.0.dev20231019": 1, + "2022.2.7": 16, + "2023.6.0.dev20230528": 1, + "2023.1.0b5": 1, + "2021.6.5": 52, + "2021.11.4": 57, + "2021.5.5": 78, + "2021.11.0": 33, + "2022.2.1": 13, + "2023.3.2": 98, + "2021.8.6": 35, + "2022.9.1": 99, + "2023.9.0.dev20230830": 1, + "2022.2.8": 44, + "2023.12.0.dev20231121": 2, + "2023.1.5": 217, + "2021.10.2": 30, + "2023.5.0.dev20230408": 1, + "2022.10.2": 57, + "2021.10.0.dev20210916": 1, + "2022.6.5": 106, + "2022.3.0": 41, + "2023.4.0": 122, + "2022.11.0b0": 1, + "2021.5.2": 13, + "2023.10.0.dev20230910": 1, + "2023.3.0b4": 1, + "2022.6.4": 73, + "2021.9.1": 6, + "2021.9.6": 82, + "2023.10.0b4": 3, + "2024.1.0.dev20231218": 99, + "2021.7.4": 106, + "2024.2.0.dev20240101": 2, + "2023.12.0b1": 46, + "2022.6.2": 59, + "2022.2.3": 76, + "2021.11.3": 53, + "2024.2.0.dev20240110": 22, + "2024.1.0b3": 54, + "2022.12.2": 23, + "2024.2.0.dev20240107": 10, + "2022.8.3": 69, + "2022.7.0": 40, + "2023.10.0.dev20230916": 1, + "2022.3.3": 103, + "2022.12.5": 56, + "2022.4.6": 90, + "2021.9.3": 18, + "2023.9.0b3": 6, + "2024.2.0.dev20240108": 14, + "2022.2.6": 98, + "2021.12.4": 49, + "2022.4.1": 62, + "2021.5.0": 11, + "2022.6.0": 31, + "2023.4.0.dev20230304": 1, + "2022.6.0b1": 1, + "2021.10.4": 32, + "2021.6.0.dev20210512": 1, + "2024.1.0b5": 32, + "2022.11.0": 40, + "2022.7.0.dev20220618": 1, + "2021.6.3": 35, + "2024.1.0b4": 27, + "2021.5.4": 34, + "2021.7.1": 36, + "2024.1.0.dev20231211": 4, + "2021.12.1": 36, + "2023.4.0.dev20230307": 3, + "2024.1.0b7": 13, + "2024.1.0b8": 15, + "2021.4.5": 33, + "2022.6.1": 47, + "2023.10.0.dev20230927": 2, + "2024.1.0.dev20231220": 6, + "2022.7.0.dev20220526": 1, + "2022.10.0.dev20220918": 1, + "2024.1.0.dev20231227": 7, + "2021.8.0": 9, + "2023.9.0b4": 1, + "2021.7.0": 4, + "2021.7.3": 46, + "2022.3.4": 30, + "2023.10.0.dev20230917": 1, + "2023.11.0b0": 3, + "2024.1.0.dev20231209": 9, + "2021.8.3": 12, + "2022.11.0.dev20221002": 1, + "2023.5.0.dev20230403": 1, + "2022.2.0.dev20211217": 1, + "2022.7.1": 16, + "2021.6.2": 28, + "2024.1.0.dev20231221": 15, + "2023.2.0.dev20230102": 4, + "2022.12.0.dev20221119": 1, + "2023.5.0.dev20230418": 1, + "2023.4.0.dev20230324": 1, + "2021.10.3": 11, + "2022.4.2": 23, + "2022.3.2": 36, + "2022.6.0.dev20220515": 1, + "2022.5.0": 37, + "2023.12.0.dev20231102": 2, + "2023.1.3": 6, + "2024.2.0.dev20231231": 2, + "2024.2.0.dev20240106": 6, + "2021.9.2": 10, + "2021.5.3": 19, + "2021.12.0": 29, + "2022.9.0.dev20220827": 2, + "2023.6.0.dev20230515": 1, + "2021.12.0b5": 1, + "2024.2.0.dev20231229": 5, + "2022.4.0.dev20220327": 1, + "2022.8.0.dev20220711": 1, + "2024.1.0.dev20231216": 7, + "2022.3.0.dev20220205": 2, + "2022.3.0b4": 1, + "2024.1.0.dev20231217": 9, + "2023.11.0b4": 2, + "2023.4.0.dev20230227": 1, + "2023.5.0b9": 1, + "2023.2.0b6": 1, + "2022.12.0.dev20221120": 1, + "2022.11.0b7": 1, + "2021.6.0": 4, + "2024.1.0.dev20231204": 1, + "2021.11.2": 29, + "2021.4.1": 6, + "2023.3.0.dev20230205": 1, + "2023.9.0b5": 2, + "2021.7.2": 23, + "2023.1.0.dev20221218": 1, + "2023.3.0.dev20230210": 3, + "2023.12.0b0": 13, + "2023.12.0b3": 5, + "2023.1.0.dev20221220": 1, + "2023.12.0.dev20231128": 5, + "2022.2.0.dev20220121": 1, + "2023.7.0.dev20230604": 1, + "2022.3.0.dev20220214": 2, + "2022.6.0.dev20220525": 2, + "2021.4.0": 3, + "2023.12.0.dev20231110": 1, + "2023.11.0.dev20231012": 4, + "2023.5.0.dev20230410": 1, + "2023.12.0b5": 5, + "2022.7.0.dev20220623": 1, + "2024.2.0.dev20231228": 3, + "2024.2.0.dev20231230": 8, + "2023.12.0.dev20231127": 1, + "2023.5.0.dev20230423": 1, + "2022.11.0.dev20221009": 1, + "2022.12.0.dev20221105": 5, + "2022.8.0.dev20220704": 1, + "2023.2.0.dev20230116": 1, + "2022.10.0b5": 1, + "2022.4.0.dev20220225": 1, + "2023.3.0.dev20230216": 1, + "2022.12.0.dev20221029": 1, + "2023.6.0.dev20230530": 2, + "2024.1.0.dev20231226": 9, + "2023.9.0.dev20230810": 2, + "2023.10.0b0": 3, + "2023.9.0b0": 2, + "2023.9.0b2": 1, + "2021.9.0.dev20210824": 1, + "2024.2.0.dev20240102": 19, + "2023.9.0.dev20230807": 2, + "2023.11.0b3": 2, + "2021.11.0.dev20211007": 1, + "2023.6.0.dev20230512": 1, + "2023.12.0.dev20231123": 2, + "2023.8.0.dev20230721": 1, + "2021.9.0": 7, + "2023.5.0b6": 1, + "2023.10.0b2": 4, + "2023.10.0.dev20230920": 1, + "2023.4.0.dev20230223": 1, + "2023.11.0b2": 6, + "2023.12.0.dev20231116": 1, + "2023.5.0.dev20230416": 1, + "2023.10.0b9": 3, + "2022.8.0b7": 1, + "2022.8.0b3": 1, + "2022.10.0.dev20220903": 2, + "2023.12.0b2": 13, + "2021.10.0": 21, + "2023.6.0.dev20230520": 1, + "2022.9.3": 8, + "2023.4.0.dev20230320": 3, + "2022.5.0.dev20220423": 1, + "2023.3.0.dev20230201": 1, + "2023.7.0.dev20230616": 1, + "2022.2.0.dev20220103": 1, + "2023.10.0b3": 3, + "2023.2.0.dev20221230": 1, + "2021.10.1": 7, + "2022.9.0.dev20220823": 1, + "2023.6.0b1": 1, + "2022.6.0.dev20220504": 1, + "2021.8.2": 7, + "2024.1.0.dev20231225": 5, + "2023.9.0.dev20230728": 1, + "2023.12.0.dev20231118": 2, + "2022.6.0.dev20220429": 1, + "2023.8.0.dev20230701": 1, + "2023.12.0.dev20231103": 1, + "2023.4.0.dev20230318": 1, + "2021.11.0.dev20211020": 1, + "2023.1.0.dev20221222": 2, + "2023.8.0b1": 2, + "2023.5.0.dev20230412": 2, + "2022.2.0.dev20220108": 1, + "2021.9.0.dev20210823": 1, + "2024.1.0.dev20231213": 9, + "2023.4.0b6": 1, + "2021.8.1": 7, + "2024.1.0.dev20231201": 3, + "2023.7.0b2": 1, + "2023.5.0.dev20230331": 1, + "2023.11.0.dev20231017": 1, + "2023.12.0.dev20231111": 1, + "2024.1.0.dev20231130": 1, + "2023.7.0.dev20230606": 1, + "2023.12.0.dev20231119": 5, + "2024.1.0.dev20231219": 6, + "2022.1.0.dev20211212": 1, + "2023.12.0.dev20231107": 1, + "2022.2.0.dev20211225": 1, + "2023.4.0.dev20230312": 1, + "2023.12.0.dev20231126": 6, + "2022.9.0b6": 2, + "2023.4.0.dev20230326": 1, + "2023.3.0.dev20230203": 1, + "2024.1.0.dev20231205": 2, + "2023.5.0.dev20230411": 1, + "2023.12.0b4": 6, + "2022.5.0.dev20220410": 3, + "2022.9.0.dev20220901": 3, + "2023.4.0b5": 2, + "2023.6.0b0": 1, + "2024.1.0.dev20231208": 3, + "2023.2.0b2": 2, + "2023.1.0.dev20221217": 1, + "2022.12.0.dev20221123": 1, + "2023.5.0.dev20230406": 2, + "2022.5.0.dev20220411": 1, + "2022.12.0.dev20221112": 1, + "2023.1.0.dev20221228": 2, + "2023.5.0.dev20230417": 1, + "2023.5.0.dev20230401": 2, + "2023.6.0.dev20230428": 1, + "2023.1.0b3": 1, + "2022.2.0b6": 1, + "2023.4.0.dev20230305": 1, + "2021.7.0.dev20210603": 1, + "2023.12.0.dev20231125": 2, + "2021.5.0.dev20210422": 1, + "2021.12.0b1": 1, + "2023.3.0b3": 3, + "2023.6.0.dev20230502": 1, + "2023.11.0.dev20231008": 1, + "2021.10.0b5": 1, + "2023.12.0.dev20231029": 2, + "2023.6.0.dev20230511": 1, + "2023.6.0.dev20230519": 1, + "2023.6.0b4": 2, + "2023.2.0.dev20230120": 2, + "2021.9.0b3": 1, + "2023.9.0.dev20230805": 1, + "2022.11.0b6": 3, + "2023.6.0.dev20230527": 1, + "2022.8.0.dev20220721": 1, + "2023.6.0.dev20230523": 1, + "2022.12.0.dev20221125": 1, + "2022.3.0.dev20220220": 1, + "2023.5.0.dev20230424": 1, + "2022.11.0b4": 1, + "2023.3.0.dev20230222": 1, + "2023.11.0b1": 1, + "2023.6.0.dev20230508": 1, + "2023.9.0.dev20230812": 1, + "2022.7.0b5": 1, + "2023.4.0.dev20230329": 1, + "2023.11.0b5": 3, + "2023.1.0b0": 1, + "2022.10.0b1": 1, + "2022.12.0b4": 1, + "2023.1.0.dev20221204": 1, + "2023.7.0b3": 2, + "2021.6.1": 2, + "2023.1.0.dev20221221": 1, + "2022.11.0b3": 1, + "2023.3.0.dev20230130": 1, + "2021.10.0.dev20210911": 1, + "2023.9.0.dev20230821": 1, + "2022.10.0b6": 1, + "2021.9.0.dev20210814": 1, + "2024.1.0.dev20231207": 2, + "2023.12.0.dev20231106": 1, + "2022.8.0.dev20220630": 1, + "2021.4.2": 4, + "2023.12.0.dev20231113": 1, + "2022.3.0.dev20220212": 1, + "2023.8.0b4": 2, + "2023.7.0.dev20230624": 1, + "2022.9.0.dev20220731": 1, + "2023.1.0.dev20221201": 1, + "2023.4.0.dev20230303": 1, + "2022.10.0.dev20220928": 1, + "2023.12.0.dev20231108": 1, + "2021.9.0b7": 1, + "2022.12.0.dev20221110": 1, + "2022.6.0.dev20220509": 1, + "2022.4.0.dev20220323": 1, + "2023.5.0.dev20230414": 3, + "2022.2.0.dev20211228": 1, + "2023.3.0b7": 1, + "2023.4.0.dev20230310": 1, + "2023.8.0.dev20230725": 1, + "2023.4.0.dev20230224": 1, + "2024.1.0.dev20231210": 1, + "2022.10.0.dev20220925": 1, + "2022.12.0.dev20221115": 1, + "2022.6.0.dev20220510": 1, + "2023.4.0.dev20230325": 1, + "2023.6.0.dev20230509": 1, + "2023.6.0.dev20230501": 1, + "2023.7.0.dev20230623": 1, + "2022.12.0.dev20221111": 1, + "2023.10.0.dev20230922": 1, + "2021.5.0.dev20210410": 1, + "2023.12.0.dev20231114": 1, + "2023.9.0.dev20230727": 1, + "2023.12.0.dev20231120": 1, + "2023.10.0.dev20230904": 1, + "2022.7.0.dev20220621": 1, + "2023.7.0.dev20230611": 1, + "2023.6.0b3": 1, + "2023.10.0b6": 1, + "2023.4.0.dev20230311": 1, + "2023.11.0b6": 1, + "2022.12.0b2": 1, + "2023.3.0.dev20230202": 1, + "2023.10.0.dev20230918": 1, + "2023.11.0.dev20231009": 1, + "2022.2.0.dev20220123": 1, + "2023.6.0.dev20230526": 1, + "2023.4.0.dev20230313": 1, + "2023.12.0.dev20231124": 1, + "2023.6.0b6": 1, + "2023.2.0.dev20230124": 1, + "2023.4.0.dev20230309": 1, + "2023.8.0.dev20230712": 2, + "2022.8.0.dev20220725": 1, + "2023.3.0.dev20230127": 1, + "2022.7.0b3": 1, + "2022.5.0.dev20220406": 1, + "2023.9.0.dev20230826": 1, + "2023.2.0.dev20230115": 1, + "2023.12.0.dev20231115": 1, + "2022.5.0b3": 1, + "2023.2.0b1": 1, + "2022.4.0.dev20220330": 1, + "2023.2.0.dev20230121": 2, + "2022.12.0.dev20221108": 1, + "2023.11.0.dev20231011": 1, + "2022.9.0b5": 1, + "2023.11.0.dev20231007": 1, + "2024.1.0.dev20231203": 2, + "2024.1.0b6": 2, + "2023.5.0.dev20230402": 1, + "2023.5.0.dev20230426": 2, + "2023.6.0.dev20230507": 1, + "2021.8.0.dev20210707": 1, + "2022.7.0.dev20220616": 1, + "2022.11.0b5": 1, + "2023.1.0.dev20221208": 1, + "2021.5.0.dev20210427": 1, + "2022.7.0.dev20220603": 1, + "2023.6.0.dev20230522": 1, + "2023.4.0.dev20230327": 1, + "2023.11.0.dev20231001": 1, + "2021.8.0.dev20210716": 1, + "2023.12.0.dev20231101": 1, + "2023.6.0.dev20230524": 1, + "2023.5.0.dev20230421": 1, + "2022.12.0b7": 1 + }, + "certificate_count_configured": 19511, + "energy": { + "count_configured": 90827 + }, + "recorder": { + "engines": { + "sqlite": { + "versions": { + "3.41.2": 198778, + "3.40.1": 3763, + "3.38.5": 4503, + "3.42.0": 2753, + "3.39.3": 187, + "3.37.2": 666, + "3.31.1": 864, + "3.43.1": 47, + "3.39.2": 13, + "3.43.0": 19, + "3.34.1": 284, + "3.44.2": 251, + "3.36.0": 30, + "3.39.4": 42, + "3.43.2": 56, + "3.39.5": 2, + "3.44.0": 87, + "3.44.1": 4, + "3.32.3": 2, + "3.41.1": 2, + "3.38.2": 1, + "3.37.0": 2, + "3.41.0": 5, + "3.40.0": 3, + "3.35.5": 3, + "3.37.1": 1, + "3.39.0": 3 + }, + "count_configured": 212371 + }, + "mysql": { + "versions": { + "10.6.12-MariaDB": 13673, + "10.6.12-MariaDB-0ubuntu0.22.04.1": 124, + "10.11.4-MariaDB-1~deb12u1": 91, + "8.0.31": 7, + "11.1.2-MariaDB-1:11.1.2+maria~ubu2204": 131, + "10.6.11-MariaDB-1:10.6.11+maria~ras11": 1, + "11.1.3-MariaDB-1:11.1.3+maria~deb12": 43, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2204": 621, + "10.11.2-MariaDB-log": 5, + "10.11.5-MariaDB": 102, + "8.0.32": 35, + "10.7.8-MariaDB-1:10.7.8+maria~ubu2004": 32, + "10.6.16-MariaDB-log": 1, + "10.7.7-MariaDB": 1, + "11.0.3-MariaDB-1:11.0.3+maria~ubu2204": 13, + "10.10.2-MariaDB-1:10.10.2+maria~deb10": 3, + "11.0.3-MariaDB-1:11.0.3+maria~deb11": 7, + "10.11.2-MariaDB-1": 10, + "11.2.2-MariaDB": 23, + "10.5.21-MariaDB-0+deb11u1-log": 6, + "10.6.11-MariaDB-log": 22, + "10.11.2-MariaDB-1:10.11.2+maria~ubu2204": 97, + "10.5.19-MariaDB-1:10.5.19+maria~deb11": 1, + "11.1.2-MariaDB-1:11.1.2+maria~deb12": 62, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2204": 57, + "10.6.10-MariaDB": 133, + "10.5.21-MariaDB-0+deb11u1": 100, + "10.11.4-MariaDB-1:10.11.4+maria~ubu2204": 8, + "10.6.16-MariaDB-cll-lve-log": 1, + "8.0.17": 2, + "10.10.2-MariaDB-1:10.10.2+maria~deb11": 6, + "10.5.8-MariaDB-log": 25, + "8.0.23-0ubuntu0.20.04.1": 1, + "10.11.5-MariaDB-log": 297, + "10.3.32-MariaDB": 95, + "8.0.35": 26, + "10.10.7-MariaDB-1:10.10.7+maria~deb11": 20, + "10.9.5-MariaDB": 1, + "11.0.2-MariaDB-1:11.0.2+maria~ubu2204": 50, + "10.6.14-MariaDB-1:10.6.14+maria~ubu2004": 5, + "10.6.12-MariaDB-log": 56, + "10.9.8-MariaDB-1:10.9.8+maria~deb11": 14, + "10.5.23-MariaDB-1:10.5.23+maria~ubu2004": 19, + "8.1.0": 15, + "11.2.2-MariaDB-1:11.2.2+maria~deb12": 112, + "8.2.0": 76, + "10.11.2-MariaDB": 167, + "10.9.3-MariaDB-1:10.9.3+maria~ubu2204": 13, + "10.5.19-MariaDB-1:10.5.19+maria~ubu2004": 10, + "10.8.8-MariaDB-1:10.8.8+maria~deb11": 8, + "10.11.3-MariaDB-1:10.11.3+maria~ubu2204": 26, + "10.11.3-MariaDB-1+rpi1": 11, + "10.6.12-MariaDB-1:10.6.12+maria~deb11": 2, + "10.3.29-MariaDB": 45, + "10.11.4-MariaDB": 10, + "8.0.35-0ubuntu0.22.04.1": 57, + "10.6.12-MariaDB-1:10.6.12+maria~deb10": 2, + "10.11.3-MariaDB-1": 23, + "10.4.19-MariaDB": 5, + "10.10.7-MariaDB-1:10.10.7+maria~ubu2204": 11, + "10.10.3-MariaDB-1:10.10.3+maria~ubu2204": 31, + "10.11.6-MariaDB": 7, + "10.5.18-MariaDB-log": 2, + "10.11.2-MariaDB-1:10.11.2+maria~deb11": 15, + "8.0.35-27": 4, + "10.5.11-MariaDB": 2, + "11.1.3-MariaDB-1:11.1.3+maria~ubu2204": 29, + "10.7.8-MariaDB-1:10.7.8+maria~deb11": 11, + "10.5.19-MariaDB": 4, + "10.5.23-MariaDB": 9, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2004-log": 3, + "10.10.5-MariaDB-1:10.10.5+maria~deb11": 1, + "8.0.35-0ubuntu0.20.04.1": 17, + "10.10.3-MariaDB-1:10.10.3+maria~deb11": 8, + "10.6.12-MariaDB-1:10.6.12+maria~ubu2004": 8, + "10.5.17-MariaDB-log": 15, + "10.9.3-MariaDB-1:10.9.3+maria~deb11": 6, + "10.8.8-MariaDB": 3, + "10.6.12-MariaDB-0ubuntu0.22.04.1-log": 23, + "10.11.3-MariaDB": 2, + "10.9.3-MariaDB": 5, + "10.9.4-MariaDB-1:10.9.4+maria~ubu2204": 7, + "10.9.2-MariaDB-1:10.9.2+maria~ubu2204": 6, + "8.0.34-0ubuntu0.22.04.1": 3, + "10.6.14-MariaDB": 9, + "10.5.18-MariaDB-1:10.5.18+maria~ubu2004": 7, + "11.1.2-MariaDB": 7, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2004": 23, + "10.5.18-MariaDB-0+deb11u1": 21, + "10.6.13-MariaDB-1:10.6.13+maria~ubu1804": 1, + "10.11.4-MariaDB-log": 40, + "10.8.8-MariaDB-1:10.8.8+maria~ubu2204": 10, + "10.3.38-MariaDB-0+deb10u1": 3, + "10.10.2-MariaDB": 2, + "10.4.8-MariaDB-1:10.4.8+maria~bionic": 1, + "10.11.5-MariaDB-1:10.11.5+maria~ubu2204": 14, + "10.5.18-MariaDB": 5, + "8.0.27": 15, + "10.3.12-MariaDB-1:10.3.12+maria~bionic": 1, + "10.7.5-MariaDB-1:10.7.5+maria~ubu2004": 7, + "10.11.6-MariaDB-1:10.11.6+maria~ubu2204": 48, + "10.11.3-MariaDB-log": 2, + "8.0.26": 2, + "10.5.22-MariaDB": 7, + "10.6.16-MariaDB-1:10.6.16+maria~deb11": 4, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2204-log": 12, + "8.0.19": 2, + "10.10.7-MariaDB-1:10.10.7+maria~deb10": 1, + "10.6.11-MariaDB": 13, + "10.9.4-MariaDB": 2, + "11.1.2-MariaDB-1:11.1.2+maria~ubu2204-log": 1, + "11.2.2-MariaDB-1:11.2.2+maria~deb11": 8, + "8.0.28": 4, + "8.0.29": 15, + "11.2.2-MariaDB-log": 4, + "10.8.7-MariaDB-1:10.8.7+maria~ubu2204": 2, + "8.0.30": 12, + "10.7.8-MariaDB": 2, + "10.10.3-MariaDB": 4, + "11.1.3-MariaDB-log": 1, + "8.0.34-26": 6, + "10.5.19-MariaDB-0+deb11u2": 34, + "10.8.2-MariaDB-1:10.8.2+maria~focal": 2, + "10.11.6-MariaDB-1:10.11.6+maria~deb11": 17, + "10.6.10-MariaDB-log": 13, + "10.11.3-MariaDB-1:10.11.3+maria~deb11": 3, + "10.6.14-MariaDB-log": 4, + "10.5.17-MariaDB-1:10.5.17+maria~ubu2004": 9, + "8.0.34": 8, + "8.0.22": 2, + "10.3.39-MariaDB-0+deb10u1": 12, + "10.9.7-MariaDB-1:10.9.7+maria~deb11": 4, + "10.6.10-MariaDB-1:10.6.10+maria~ubu1804": 1, + "10.5.22-MariaDB-1:10.5.22+maria~ubu2004-log": 2, + "10.11.4-MariaDB-1~deb12u1-log": 9, + "10.11.3-MariaDB-1:10.11.3+maria~ubu2204-log": 3, + "10.8.3-MariaDB": 1, + "10.4.17-MariaDB-1:10.4.17+maria~bionic-log": 1, + "10.4.19-MariaDB-1:10.4.19+maria~focal": 1, + "10.8.6-MariaDB-1:10.8.6+maria~ubu2204": 4, + "10.3.37-MariaDB": 7, + "10.5.8-MariaDB": 2, + "8.0.25": 6, + "10.11.4-MariaDB-1": 5, + "10.6.12-MariaDB-1:10.6.12+maria~ubu1804": 2, + "10.5.21-MariaDB": 1, + "10.7.7-MariaDB-1:10.7.7+maria~ubu2004": 2, + "10.11.6-MariaDB-1": 1, + "10.6.11-MariaDB-0ubuntu0.22.04.1": 3, + "10.3.38-MariaDB-0ubuntu0.20.04.1": 11, + "10.11.5-MariaDB-1:10.11.5+maria~deb11": 8, + "8.0.24": 2, + "11.1.0-MariaDB-log": 1, + "10.11.6-MariaDB-1:10.11.6+maria~ubu2204-log": 5, + "10.6.12-MariaDB-1:10.6.12+maria~ubu2004-log": 3, + "10.6.5-MariaDB-1:10.6.5+maria~focal": 2, + "10.5.17-MariaDB": 3, + "11.0.2-MariaDB": 4, + "11.0.2-MariaDB-1:11.0.2+maria~ubu2204-log": 1, + "10.3.7-MariaDB": 2, + "10.10.6-MariaDB-1:10.10.6+maria~deb11": 2, + "8.0.23": 5, + "10.6.16-MariaDB": 5, + "8.0.33": 20, + "10.9.5-MariaDB-1:10.9.5+maria~ubu2204": 5, + "11.1.2-MariaDB-1:11.1.2+maria~deb11": 6, + "10.6.12-MariaDB-0ubuntu0.22.10.1": 2, + "10.6.15-MariaDB-log": 1, + "10.11.6-MariaDB-1:10.11.6+maria~deb12": 3, + "10.10.6-MariaDB-1:10.10.6+maria~ubu2004": 2, + "10.9.3-MariaDB-1:10.9.3+maria~ubu2204-log": 2, + "10.6.13-MariaDB-log": 14, + "10.5.23-MariaDB-log": 2, + "8.0.34-26.1": 1, + "10.10.3-MariaDB-1:10.10.3+maria~ubu2004": 3, + "11.1.3-MariaDB-1:11.1.3+maria~deb12-log": 1, + "10.6.11-MariaDB-1:10.6.11+maria~ubu2004": 12, + "10.6.11-MariaDB-1:10.6.11+maria~deb10": 1, + "10.8.6-MariaDB-1:10.8.6+maria~deb11": 5, + "8.0.33-0ubuntu0.20.04.2": 1, + "11.1.2-MariaDB-log": 2, + "10.3.39-MariaDB-1:10.3.39+maria~ubu2004": 1, + "10.4.28-MariaDB": 2, + "8.0.31-google": 4, + "10.3.37-MariaDB-0ubuntu0.20.04.1": 1, + "10.8.4-MariaDB-1:10.8.4+maria~ubu2204": 8, + "10.5.20-MariaDB-1:10.5.20+maria~ubu2004": 2, + "11.0.4-MariaDB-log": 1, + "10.5.21-MariaDB-log": 2, + "10.9.2-MariaDB-1:10.9.2+maria~deb11": 1, + "10.5.22-MariaDB-log": 1, + "11.0.4-MariaDB-1:11.0.4+maria~deb11": 8, + "10.11.6-MariaDB-1-log": 1, + "10.5.18-MariaDB-0+deb11u1-log": 3, + "10.6.8-MariaDB": 13, + "10.5.22-MariaDB-1:10.5.22+maria~ubu2004": 5, + "11.0.1-MariaDB": 1, + "11.1.3-MariaDB-1:11.1.3+maria~deb11": 4, + "8.0.30-0ubuntu0.20.04.2": 1, + "10.3.36-MariaDB-0+deb10u2": 3, + "8.0.31-23": 1, + "10.6.13-MariaDB": 3, + "10.10.5-MariaDB-1:10.10.5+maria~ubu2004": 1, + "10.5.23-MariaDB-1:10.5.23+maria~ubu2004-log": 5, + "10.5.23-MariaDB-1:10.5.23+maria~deb11": 3, + "10.3.29-MariaDB-log": 2, + "11.0.3-MariaDB-1:11.0.3+maria~ubu2204-log": 1, + "10.5.16-MariaDB": 1, + "8.0.32-0ubuntu0.20.04.2": 1, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2204": 1, + "8.0.35-1ubuntu2": 1, + "10.11.3-MariaDB-1:10.11.3+maria~deb11-log": 1, + "10.9.8-MariaDB-1:10.9.8+maria~deb10": 2, + "10.4.21-MariaDB-1:10.4.21+maria~focal": 1, + "10.4.26-MariaDB": 1, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2004": 2, + "10.6.11-MariaDB-1:10.6.11+maria~ubu2004-log": 1, + "10.3.27-MariaDB-0+deb10u1": 3, + "10.8.8-MariaDB-1:10.8.8+maria~ubu2004": 1, + "10.11.6-MariaDB-1:10.11.6+maria~deb10": 1, + "10.9.5-MariaDB-1:10.9.5+maria~deb11": 2, + "10.11.6-MariaDB-1:10.11.6+maria~deb12-log": 1, + "10.9.8-MariaDB-1:10.9.8+maria~ubu2204": 7, + "10.5.22-MariaDB-1:10.5.22+maria~ubu1804": 2, + "10.11.5-MariaDB-3": 2, + "10.6.15-MariaDB-1:10.6.15+maria~ubu2004": 6, + "10.11.4-MariaDB-1:10.11.4+maria~deb11": 3, + "11.3.1-MariaDB": 1, + "10.5.22-MariaDB-1:10.5.22+maria~ras11": 1, + "10.6.14-MariaDB-1:10.6.14+maria~ubu2204": 1, + "8.0.26-google": 1, + "8.0.13": 1, + "10.5.20-MariaDB-1:10.5.20+maria~ras10": 1, + "11.0.3-MariaDB": 1, + "10.5.15-MariaDB-0+deb11u1": 4, + "11.0.4-MariaDB-1:11.0.4+maria~ubu2204-log": 1, + "10.5.13-MariaDB-log": 1, + "10.5.13-MariaDB": 2, + "10.11.6-MariaDB-2": 1, + "10.5.8-MariaDB-1:10.5.8+maria~focal": 1, + "10.10.6-MariaDB-1:10.10.6+maria~deb10": 1, + "8.0.35-1": 1, + "8.0.21": 2, + "10.3.39-MariaDB-0+deb10u1-log": 1, + "10.3.37-MariaDB-log": 1, + "8.0.18": 1, + "10.7.3-MariaDB": 3, + "10.6.10-MariaDB-1:10.6.10+maria~ubu2004": 1, + "10.6.16-MariaDB-cll-lve": 1, + "10.6.13-MariaDB-1:10.6.13+maria~ubu2204-log": 1, + "10.6.15-MariaDB-1:10.6.15+maria~deb10-log": 1, + "10.11.3-MariaDB-1+rpi1-log": 1, + "8.0.29-0ubuntu0.20.04.3": 2, + "10.3.31-MariaDB-0+deb10u1": 1, + "10.5.16-MariaDB-log": 1, + "10.4.24-MariaDB": 1, + "10.6.13-MariaDB-1:10.6.13+maria~ubu2004": 1, + "11.1.3-MariaDB": 4, + "8.0.20-0ubuntu0.19.10.1": 1, + "11.0.2-MariaDB-1:11.0.2+maria~deb11": 3, + "10.3.39-MariaDB-1:10.3.39+maria~ubu1804": 1, + "10.3.39-MariaDB-1:10.3.39+maria~ubu1804-log": 1, + "10.10.6-MariaDB-1:10.10.6+maria~ubu2204": 1, + "8.0.35-0ubuntu0.23.04.1": 2, + "11.0.2-MariaDB-log": 1, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2204-log": 1, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2004": 1, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2304": 1, + "8.0.34-0ubuntu0.20.04.1": 1, + "10.6.9-MariaDB-1:10.6.9+maria~ubu2004": 2, + "8.0.35-0ubuntu0.23.10.1": 1, + "10.6.15-MariaDB-cll-lve-log": 1, + "10.5.15-MariaDB-log": 1, + "8.0.33-0ubuntu0.22.04.2": 1, + "10.7.6-MariaDB-1:10.7.6+maria~deb11": 1, + "10.5.19-MariaDB-0+deb11u2-log": 1, + "10.9.8-MariaDB": 1, + "10.6.16-MariaDB-1:10.6.16+maria~deb10": 1, + "10.3.39-MariaDB-log-cll-lve": 2, + "10.5.19-MariaDB-1:10.5.19+maria~ubu1804": 1, + "10.10.7-MariaDB-1:10.10.7+maria~ubu2004": 1, + "10.7.7-MariaDB-1:10.7.7+maria~deb11": 2, + "8.0.20": 1, + "10.11.4-MariaDB-1:10.11.4+maria~ubu2004": 2, + "8.0.32-0ubuntu0.22.04.2": 3, + "10.5.15-MariaDB": 1, + "10.10.7-MariaDB-log": 1, + "10.5.21-MariaDB-1:10.5.21+maria~ubu2004": 3, + "10.8.3-MariaDB-1:10.8.3+maria~jammy": 1, + "10.11.5-MariaDB-1:10.11.5+maria~ubu2204-log": 2, + "10.6.15-MariaDB": 3, + "10.4.14-MariaDB-1:10.4.14+maria~bionic": 1, + "10.7.6-MariaDB-1:10.7.6+maria~ubu2004": 1, + "10.6.9-MariaDB-log": 2, + "10.11.2-MariaDB-1:10.11.2+maria~deb10": 2, + "10.10.7-MariaDB": 1, + "8.0.32-1": 1, + "10.9.4-MariaDB-1:10.9.4+maria~deb11": 3, + "8.0.34-Vitess": 1, + "8.0.29-21": 1, + "10.5.19-MariaDB-1:10.5.19+maria~ras10": 1, + "10.10.6-MariaDB": 1, + "10.5.10-MariaDB-1:10.5.10+maria~bionic": 1, + "10.4.25-MariaDB-1:10.4.25+maria~focal": 1 + }, + "count_configured": 17679 + }, + "postgresql": { + "versions": { + "16.0 (Debian 16.0-1.pgdg110+1)": 92, + "13.1 (Debian 13.1-1.pgdg100+1)": 912, + "16.1": 39, + "15.5 (Debian 15.5-1.pgdg120+1)": 67, + "15.3 (Debian 15.3-1.pgdg120+1)": 16, + "15.2 (Debian 15.2-1.pgdg110+1)": 36, + "14.2": 5, + "14.10 (Debian 14.10-1.pgdg120+1)": 60, + "12.2 (Debian 12.2-2.pgdg100+1)": 5, + "14.10 (Ubuntu 14.10-0ubuntu0.22.04.1)": 27, + "13.5 (Debian 13.5-1.pgdg110+1)": 2, + "15.4": 28, + "15.3": 136, + "13.13 (Debian 13.13-0+deb11u1)": 24, + "14.3 (Debian 14.3-1.pgdg110+1)": 8, + "15.4 (Debian 15.4-1.pgdg120+1)": 10, + "16.1 (Debian 16.1-1.pgdg120+1)": 72, + "14.4 (Debian 14.4-1.pgdg110+1)": 7, + "15.3 (Debian 15.3-1.pgdg110+1)": 71, + "13.13": 11, + "13.3 (Debian 13.3-1.pgdg100+1)": 3, + "15.4 (Debian 15.4-3)": 2, + "14.7 (Debian 14.7-1.pgdg110+1)": 9, + "14.1 (Debian 14.1-1.pgdg110+1)": 8, + "13.13 (Debian 13.13-1.pgdg120+1)": 19, + "15.5 (Debian 15.5-0+deb12u1)": 38, + "14.10 (Ubuntu 14.10-1.pgdg22.04+1)": 4, + "14.7 (Ubuntu 14.7-1.pgdg22.04+1)": 8, + "15.5": 41, + "13.10": 3, + "14.9": 13, + "14.5 (Debian 14.5-2.pgdg110+2)": 10, + "12.9 (Ubuntu 12.9-0ubuntu0.20.04.1)": 1, + "14.8 (Debian 14.8-1.pgdg100+1)": 1, + "14.7": 9, + "14.2 (Debian 14.2-1.pgdg110+1)": 11, + "12.11 (Ubuntu 12.11-1.pgdg18.04+1)": 1, + "14.3": 7, + "16.0 (Debian 16.0-1.pgdg120+1)": 30, + "16.1 (Debian 16.1-1.pgdg110+1)": 30, + "13.4 (Debian 13.4-4.pgdg110+1)": 6, + "12.8 (Ubuntu 12.8-0ubuntu0.20.04.1)": 1, + "14.8 (Debian 14.8-1.pgdg110+1)": 4, + "15.2 (Ubuntu 15.2-1.pgdg22.04+1)": 12, + "13.4 (Debian 13.4-1.pgdg100+1)": 1, + "13.11 (Raspbian 13.11-0+deb11u1)": 3, + "14.1": 13, + "12.3 (Debian 12.3-1.pgdg100+1)": 2, + "12.16 (Debian 12.16-1.pgdg120+1)": 2, + "15.1": 6, + "12.11 (Debian 12.11-1.pgdg110+1)": 2, + "12.17 (Ubuntu 12.17-0ubuntu0.20.04.1)": 8, + "14.9 (Debian 14.9-1.pgdg100+1)": 2, + "15.5 (Ubuntu 15.5-1.pgdg22.04+1)": 8, + "16.1 (Ubuntu 16.1-1.pgdg22.04+1)": 10, + "12.5": 4, + "13.2 (Debian 13.2-1.pgdg100+1)": 5, + "14.9 (Debian 14.9-1.pgdg120+1)": 5, + "14.5": 18, + "14.6": 4, + "16.0": 5, + "15.4 (Debian 15.4-2.pgdg110+1)": 4, + "15.2": 6, + "14.10": 38, + "14.5 (Debian 14.5-1.pgdg110+1)": 10, + "12.15 (Debian 12.15-1.pgdg110+1)": 2, + "13.11 (Debian 13.11-0+deb11u1)": 6, + "12.11 (Ubuntu 12.11-0ubuntu0.20.04.1)": 3, + "15.1 (Ubuntu 15.1-1.pgdg22.04+1)": 1, + "15.4 (Debian 15.4-1.pgdg110+1)": 4, + "14.9 (Ubuntu 14.9-0ubuntu0.22.04.1)": 9, + "15.4 (Debian 15.4-1.pgdg100+1)": 1, + "13.8 (Debian 13.8-1.pgdg110+1)": 3, + "14.8": 5, + "13.9": 2, + "12.17 (Debian 12.17-1.pgdg120+1)": 12, + "14.7 (Ubuntu 14.7-0ubuntu0.22.04.1)": 1, + "12.14 (Ubuntu 12.14-0ubuntu0.20.04.1)": 3, + "13.11": 3, + "12.15 (Ubuntu 12.15-1.pgdg18.04+1)": 2, + "15.4 (Ubuntu 15.4-1ubuntu1)": 1, + "12.16 (Debian 12.16-1.pgdg110+1)": 1, + "12.15 (Ubuntu 12.15-0ubuntu0.20.04.1)": 1, + "16.0 (Debian 16.0-1.pgdg100+1)": 1, + "15.1 (Debian 15.1-1.pgdg110+1)": 10, + "12.6": 1, + "13.0 (Debian 13.0-1.pgdg100+1)": 1, + "13.9 (Debian 13.9-1.pgdg110+1)": 2, + "14.10 (Debian 14.10-1.pgdg110+1)": 11, + "14.10 (Ubuntu 14.10-1.pgdg20.04+1)": 2, + "14.8 (Debian 14.8-1.pgdg120+1)": 4, + "12.13 (Debian 12.13-1.pgdg110+1)": 4, + "14.6 (Debian 14.6-1.pgdg110+1)": 7, + "12.9": 1, + "13.12 (Debian 13.12-1.pgdg120+1)": 8, + "13.0": 2, + "13.6 (Debian 13.6-1.pgdg110+1)": 4, + "12.14": 2, + "13.10 (Debian 13.10-1.pgdg110+1)": 3, + "16.1 (Ubuntu 16.1-1.pgdg20.04+1)": 1, + "12.15 (Debian 12.15-1.pgdg120+1)": 3, + "12.7 (Debian 12.7-1.pgdg100+1)": 1, + "13.13 (Raspbian 13.13-0+deb11u1)": 4, + "12.14 (Ubuntu 12.14-1.pgdg22.04+1)": 1, + "13.5": 1, + "12.5 (Debian 12.5-1.pgdg100+1)": 1, + "12.17": 10, + "12.12": 2, + "13.7 (Ubuntu 13.7-0ubuntu0.21.10.1)": 1, + "13.9 (Debian 13.9-0+deb11u1)": 3, + "12.15": 1, + "13.2": 3, + "12.7": 3, + "12.3": 1, + "15.4 (Ubuntu 15.4-2.pgdg23.04+1)": 2, + "15.3 (Debian 15.3-0+deb12u1)": 2, + "13.12 (Debian 13.12-1.pgdg110+1)": 1, + "13.8": 1, + "15.4 (Debian 15.4-2.pgdg120+1)": 9, + "12.16 (Ubuntu 12.16-0ubuntu0.20.04.1)": 2, + "15.3 (Ubuntu 15.3-1.pgdg22.04+1)": 3, + "16.1 (Debian 16.1-1)": 1, + "13.12": 4, + "14.6 (Ubuntu 14.6-1.pgdg22.04+1)": 1, + "12.8": 1, + "13.7 (Debian 13.7-1.pgdg110+1)": 2, + "12.4": 1, + "15.1 (Ubuntu 15.1-1.pgdg18.04+1)": 1, + "14.9 (Debian 14.9-1.pgdg110+1)": 2, + "14.0 (Debian 14.0-1.pgdg110+1)": 4, + "13.7 (Debian 13.7-0+deb11u1)": 2, + "15.5 (Debian 15.5-1.pgdg110+1)": 3, + "12.10": 2, + "12.14 (Debian 12.14-1.pgdg110+1)": 3, + "12.13 (Ubuntu 12.13-0ubuntu0.20.04.1)": 1, + "12.2": 1, + "13.9 (Debian 13.9-1.pgdg100+1)": 1, + "14.8 (Ubuntu 14.8-1.pgdg20.04+1)": 1, + "15.0 (Debian 15.0-1.pgdg110+1)": 4, + "13.8 (Debian 13.8-0+deb11u1)": 2, + "13.5 (Debian 13.5-1.pgdg100+1)": 1, + "13.11 (Debian 13.11-1.pgdg110+1)": 2, + "13.4": 3, + "15.0": 3, + "12.17 (Debian 12.17-1.pgdg110+1)": 2, + "14.7 (Ubuntu 14.7-1.pgdg20.04+1)": 1, + "13.11 (Debian 13.11-1.pgdg100+1)": 1, + "15.5 (Ubuntu 15.5-0ubuntu0.23.10.1)": 1, + "14.5 (Ubuntu 14.5-2.pgdg20.04+2)": 1, + "14.2 (Ubuntu 14.2-1ubuntu1)": 1, + "12.11": 1, + "13.7": 2, + "15.4 (Ubuntu 15.4-2.pgdg22.04+1)": 2, + "13.12 (Debian 13.12-1.pgdg100+1)": 1, + "14.4": 2, + "16.0 (Ubuntu 16.0-1.pgdg22.04+1)": 2, + "14.0": 2, + "14.2 (Ubuntu 14.2-1.pgdg20.04+1)": 1, + "12.16": 1, + "13.5 (Ubuntu 13.5-0ubuntu0.21.04.1)": 1, + "12.17 (Ubuntu 12.17-1.pgdg20.04+1)": 1, + "13.3": 2, + "14.8 (Ubuntu 14.8-1.pgdg22.04+1)": 1, + "14.2 (Ubuntu 14.2-1.pgdg18.04+1)": 1, + "15.5 (Debian 15.5-1.pgdg100+1)": 1, + "12.8 (Debian 12.8-1.pgdg100+1)": 1, + "14.10 (Ubuntu 14.10-1.pgdg23.04+1)": 1, + "15.5 (Ubuntu 15.5-1.pgdg20.04+1)": 1, + "13.6": 1 + }, + "count_configured": 2312 + } + } + }, + "extended_data_from": 310156 +} diff --git a/tests/components/analytics_insights/fixtures/custom_integrations.json b/tests/components/analytics_insights/fixtures/custom_integrations.json new file mode 100644 index 00000000000..5777c8f1d06 --- /dev/null +++ b/tests/components/analytics_insights/fixtures/custom_integrations.json @@ -0,0 +1,10 @@ +{ + "hacs": { + "total": 157481, + "versions": { + "1.33.0": 123794, + "1.30.1": 1684, + "1.14.1": 23 + } + } +} diff --git a/tests/components/analytics_insights/fixtures/integrations.json b/tests/components/analytics_insights/fixtures/integrations.json new file mode 100644 index 00000000000..d43f75ab32e --- /dev/null +++ b/tests/components/analytics_insights/fixtures/integrations.json @@ -0,0 +1,16 @@ +{ + "youtube": { + "title": "YouTube", + "description": "Instructions on how to integrate YouTube within Home Assistant.", + "quality_scale": "", + "iot_class": "Cloud Polling", + "integration_type": "service" + }, + "hue": { + "title": "Philips Hue", + "description": "Instructions on setting up Philips Hue within Home Assistant.", + "quality_scale": "platinum", + "iot_class": "Local Push", + "integration_type": "hub" + } +} diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dc4c3d6d795 --- /dev/null +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-entry] + EntityRegistryEntrySnapshot({ + '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.homeassistant_analytics_hacs_custom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'hacs (custom)', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'custom_integrations', + 'unique_id': 'custom_hacs_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics hacs (custom)', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_hacs_custom', + 'last_changed': , + 'last_updated': , + 'state': '157481', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_myq-entry] + EntityRegistryEntrySnapshot({ + '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.homeassistant_analytics_myq', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'myq', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_integrations', + 'unique_id': 'core_myq_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_myq-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics myq', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_myq', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_spotify-entry] + EntityRegistryEntrySnapshot({ + '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.homeassistant_analytics_spotify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'spotify', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_integrations', + 'unique_id': 'core_spotify_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_spotify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics spotify', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_spotify', + 'last_changed': , + 'last_updated': , + 'state': '24388', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_youtube-entry] + EntityRegistryEntrySnapshot({ + '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.homeassistant_analytics_youtube', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'YouTube', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_integrations', + 'unique_id': 'core_youtube_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_youtube-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics YouTube', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_youtube', + 'last_changed': , + 'last_updated': , + 'state': '339', + }) +# --- diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py new file mode 100644 index 00000000000..6ddbe285df7 --- /dev/null +++ b/tests/components/analytics_insights/test_config_flow.py @@ -0,0 +1,288 @@ +"""Test the Homeassistant Analytics config flow.""" +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError + +from homeassistant import config_entries +from homeassistant.components.analytics_insights.const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.analytics_insights import setup_integration + + +@pytest.mark.parametrize( + ("user_input", "expected_options"), + [ + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + ), + ( + { + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ], +) +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_analytics_client: AsyncMock, + user_input: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Analytics Insights" + assert result["data"] == {} + assert result["options"] == expected_options + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + {}, + ], +) +async def test_submitting_empty_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_analytics_client: AsyncMock, + user_input: dict[str, Any], +) -> None: + """Test we can't submit an empty form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "no_integrations_selected"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Analytics Insights" + assert result["data"] == {} + assert result["options"] == { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_analytics_client: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + + mock_analytics_client.get_integrations.side_effect = ( + HomeassistantAnalyticsConnectionError + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("user_input", "expected_options"), + [ + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + ), + ( + { + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + user_input: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + mock_analytics_client.get_integrations.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == expected_options + await hass.async_block_till_done() + mock_analytics_client.get_integrations.assert_called_once() + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + {}, + ], +) +async def test_submitting_empty_options_flow( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + user_input: dict[str, Any], +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "no_integrations_selected"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + } + await hass.async_block_till_done() + + +async def test_options_flow_cannot_connect( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle cannot connect error.""" + + mock_analytics_client.get_integrations.side_effect = ( + HomeassistantAnalyticsConnectionError + ) + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/analytics_insights/test_init.py b/tests/components/analytics_insights/test_init.py new file mode 100644 index 00000000000..08b898f13c1 --- /dev/null +++ b/tests/components/analytics_insights/test_init.py @@ -0,0 +1,28 @@ +"""Test the Home Assistant analytics init module.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.analytics_insights.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.analytics_insights import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/analytics_insights/test_sensor.py b/tests/components/analytics_insights/test_sensor.py new file mode 100644 index 00000000000..83ea2885456 --- /dev/null +++ b/tests/components/analytics_insights/test_sensor.py @@ -0,0 +1,86 @@ +"""Test the Home Assistant analytics sensor module.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from python_homeassistant_analytics import ( + HomeassistantAnalyticsConnectionError, + HomeassistantAnalyticsNotModifiedError, +) +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.analytics_insights.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 hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + + +async def test_connection_error( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_analytics_client.get_current_analytics.side_effect = ( + HomeassistantAnalyticsConnectionError() + ) + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.homeassistant_analytics_spotify").state + == STATE_UNAVAILABLE + ) + + +async def test_data_not_modified( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test not updating data if its not modified.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.homeassistant_analytics_spotify").state == "24388" + mock_analytics_client.get_current_analytics.side_effect = ( + HomeassistantAnalyticsNotModifiedError + ) + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_analytics_client.get_current_analytics.assert_called() + assert hass.states.get("sensor.homeassistant_analytics_spotify").state == "24388" diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py index cbd7231f366..631a69e103b 100644 --- a/tests/components/anova/test_init.py +++ b/tests/components/anova/test_init.py @@ -26,7 +26,6 @@ async def test_wrong_login( ) -> None: """Test for setup failure if connection to Anova is missing.""" entry = create_entry(hass) - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/anthemav/test_media_player.py b/tests/components/anthemav/test_media_player.py index b4e8808f4e9..9dd8af24efb 100644 --- a/tests/components/anthemav/test_media_player.py +++ b/tests/components/anthemav/test_media_player.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry ("entity_id", "entity_name"), [ ("media_player.anthem_av", "Anthem AV"), - ("media_player.anthem_av_zone_2", "Anthem AV zone 2"), + ("media_player.zone_2", "Zone 2"), ], ) async def test_zones_loaded( diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index f2c3ffc9c3c..fe35f6b337d 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -3,6 +3,16 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from py_aosmith import AOSmithAPIClient +from py_aosmith.models import ( + Device, + DeviceStatus, + DeviceType, + EnergyUseData, + EnergyUseHistoryEntry, + HotWaterStatus, + OperationMode, + SupportedOperationModeInfo, +) import pytest from homeassistant.components.aosmith.const import DOMAIN @@ -10,11 +20,7 @@ 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, -) +from tests.common import MockConfigEntry, load_json_object_fixture FIXTURE_USER_INPUT = { CONF_EMAIL: "testemail@example.com", @@ -22,6 +28,94 @@ FIXTURE_USER_INPUT = { } +def build_device_fixture( + heat_pump: bool, mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool +): + """Build a fixture for a device.""" + supported_modes: list[SupportedOperationModeInfo] = [ + SupportedOperationModeInfo( + mode=OperationMode.ELECTRIC, + original_name="ELECTRIC", + has_day_selection=True, + ), + ] + + if heat_pump: + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.HYBRID, + original_name="HYBRID", + has_day_selection=False, + ) + ) + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.HEAT_PUMP, + original_name="HEAT_PUMP", + has_day_selection=False, + ) + ) + + if has_vacation_mode: + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.VACATION, + original_name="VACATION", + has_day_selection=True, + ) + ) + + device_type = ( + DeviceType.NEXT_GEN_HEAT_PUMP if heat_pump else DeviceType.RE3_CONNECTED + ) + + current_mode = OperationMode.HEAT_PUMP if heat_pump else OperationMode.ELECTRIC + + model = "HPTS-50 200 202172000" if heat_pump else "EE12-50H55DVF 100,3806368" + + return Device( + brand="aosmith", + model=model, + device_type=device_type, + dsn="dsn", + junction_id="junctionId", + name="My water heater", + serial="serial", + install_location="Basement", + supported_modes=supported_modes, + status=DeviceStatus( + firmware_version="2.14", + is_online=True, + current_mode=current_mode, + mode_change_pending=mode_pending, + temperature_setpoint=130, + temperature_setpoint_pending=setpoint_pending, + temperature_setpoint_previous=130, + temperature_setpoint_maximum=130, + hot_water_status=HotWaterStatus.LOW, + ), + ) + + +ENERGY_USE_FIXTURE = EnergyUseData( + lifetime_kwh=132.825, + history=[ + EnergyUseHistoryEntry( + date="2023-10-30T04:00:00.000Z", + energy_use_kwh=2.01, + ), + EnergyUseHistoryEntry( + date="2023-10-31T04:00:00.000Z", + energy_use_kwh=1.542, + ), + EnergyUseHistoryEntry( + date="2023-11-01T04:00:00.000Z", + energy_use_kwh=1.908, + ), + ], +) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -42,25 +136,52 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def get_devices_fixture() -> str: - """Return the name of the fixture to use for get_devices.""" - return "get_devices" +def get_devices_fixture_heat_pump() -> bool: + """Return whether the device in the get_devices fixture should be a heat pump water heater.""" + return True @pytest.fixture -async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: +def get_devices_fixture_mode_pending() -> bool: + """Return whether to set mode_pending in the get_devices fixture.""" + return False + + +@pytest.fixture +def get_devices_fixture_setpoint_pending() -> bool: + """Return whether to set setpoint_pending in the get_devices fixture.""" + return False + + +@pytest.fixture +def get_devices_fixture_has_vacation_mode() -> bool: + """Return whether to include vacation mode in the get_devices fixture.""" + return True + + +@pytest.fixture +async def mock_client( + get_devices_fixture_heat_pump: bool, + get_devices_fixture_mode_pending: bool, + get_devices_fixture_setpoint_pending: bool, + get_devices_fixture_has_vacation_mode: bool, +) -> 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 - ) + get_devices_fixture = [ + build_device_fixture( + heat_pump=get_devices_fixture_heat_pump, + mode_pending=get_devices_fixture_mode_pending, + setpoint_pending=get_devices_fixture_setpoint_pending, + has_vacation_mode=get_devices_fixture_has_vacation_mode, + ) + ] get_all_device_info_fixture = load_json_object_fixture( "get_all_device_info.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) + client_mock.get_energy_use_data = AsyncMock(return_value=ENERGY_USE_FIXTURE) client_mock.get_all_device_info = AsyncMock( return_value=get_all_device_info_fixture ) diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json deleted file mode 100644 index e34c50cd270..00000000000 --- a/tests/components/aosmith/fixtures/get_devices.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "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 deleted file mode 100644 index a12f1d95f13..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_mode_pending.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 249024e1f1e..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 4d6e7613cf2..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 989ddab5399..00000000000 --- a/tests/components/aosmith/fixtures/get_energy_use_data.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr index 2293a6c7b65..a4be3d107f3 100644 --- a/tests/components/aosmith/snapshots/test_water_heater.ambr +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -8,9 +8,9 @@ 'max_temp': 130, 'min_temp': 95, 'operation_list': list([ + 'electric', 'eco', 'heat_pump', - 'electric', ]), 'operation_mode': 'heat_pump', 'supported_features': , @@ -25,3 +25,23 @@ 'state': 'heat_pump', }) # --- +# name: test_state_non_heat_pump[False] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + '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': 'electric', + }) +# --- diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py index 463932e930a..7e081686790 100644 --- a/tests/components/aosmith/test_init.py +++ b/tests/components/aosmith/test_init.py @@ -15,11 +15,9 @@ from homeassistant.components.aosmith.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_array_fixture, -) +from .conftest import build_device_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: @@ -52,7 +50,14 @@ async def test_config_entry_not_ready_get_energy_use_data_error( """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) + get_devices_fixture = [ + build_device_fixture( + heat_pump=True, + mode_pending=False, + setpoint_pending=False, + has_vacation_mode=True, + ) + ] with patch( "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", @@ -68,12 +73,17 @@ async def test_config_entry_not_ready_get_energy_use_data_error( @pytest.mark.parametrize( - ("get_devices_fixture", "time_to_wait", "expected_call_count"), + ( + "get_devices_fixture_mode_pending", + "get_devices_fixture_setpoint_pending", + "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), + (False, False, REGULAR_INTERVAL, 1), + (False, False, FAST_INTERVAL, 0), + (True, False, FAST_INTERVAL, 1), + (False, True, FAST_INTERVAL, 1), ], ) async def test_update( diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py index 61cb159c82a..a256f720c0a 100644 --- a/tests/components/aosmith/test_water_heater.py +++ b/tests/components/aosmith/test_water_heater.py @@ -2,15 +2,10 @@ from unittest.mock import MagicMock +from py_aosmith.models import OperationMode 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, @@ -30,6 +25,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -59,8 +55,22 @@ async def test_state( @pytest.mark.parametrize( - ("get_devices_fixture"), - ["get_devices_no_vacation_mode"], + ("get_devices_fixture_heat_pump"), + [ + False, + ], +) +async def test_state_non_heat_pump( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity for a non heat pump device.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture_has_vacation_mode"), + [False], ) async def test_state_away_mode_unsupported( hass: HomeAssistant, init_integration: MockConfigEntry @@ -77,9 +87,9 @@ async def test_state_away_mode_unsupported( @pytest.mark.parametrize( ("hass_mode", "aosmith_mode"), [ - (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), - (STATE_ECO, AOSMITH_MODE_HYBRID), - (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + (STATE_HEAT_PUMP, OperationMode.HEAT_PUMP), + (STATE_ECO, OperationMode.HYBRID), + (STATE_ELECTRIC, OperationMode.ELECTRIC), ], ) async def test_set_operation_mode( @@ -103,6 +113,24 @@ async def test_set_operation_mode( mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) +async def test_unsupported_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the operation mode with an unsupported mode.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: "unsupported_mode", + }, + blocking=True, + ) + + async def test_set_temperature( hass: HomeAssistant, mock_client: MagicMock, @@ -120,10 +148,12 @@ async def test_set_temperature( @pytest.mark.parametrize( - ("hass_away_mode", "aosmith_mode"), + ("get_devices_fixture_heat_pump", "hass_away_mode", "aosmith_mode"), [ - (True, AOSMITH_MODE_VACATION), - (False, AOSMITH_MODE_HYBRID), + (True, True, OperationMode.VACATION), + (True, False, OperationMode.HYBRID), + (False, True, OperationMode.VACATION), + (False, False, OperationMode.ELECTRIC), ], ) async def test_away_mode( diff --git a/tests/components/apache_kafka/conftest.py b/tests/components/apache_kafka/conftest.py deleted file mode 100644 index 9391ccdd380..00000000000 --- a/tests/components/apache_kafka/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/apache_kafka/test_init.py b/tests/components/apache_kafka/test_init.py index 3c7da1be48d..2f8b035cda9 100644 --- a/tests/components/apache_kafka/test_init.py +++ b/tests/components/apache_kafka/test_init.py @@ -62,7 +62,7 @@ async def test_minimal_config( config = {apache_kafka.DOMAIN: MIN_CONFIG} assert await async_setup_component(hass, apache_kafka.DOMAIN, config) await hass.async_block_till_done() - assert mock_client.start.called_once + mock_client.start.assert_called_once() async def test_full_config(hass: HomeAssistant, mock_client: MockKafkaClient) -> None: @@ -83,7 +83,7 @@ async def test_full_config(hass: HomeAssistant, mock_client: MockKafkaClient) -> assert await async_setup_component(hass, apache_kafka.DOMAIN, config) await hass.async_block_till_done() - assert mock_client.start.called_once + mock_client.start.assert_called_once() async def _setup(hass, filter_config): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 08cb77b4559..49da0078229 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -254,16 +254,20 @@ async def test_api_get_config(hass: HomeAssistant, mock_api_client: TestClient) """Test the return of the configuration.""" resp = await mock_api_client.get(const.URL_API_CONFIG) result = await resp.json() - if "components" in result: - result["components"] = set(result["components"]) - if "whitelist_external_dirs" in result: - result["whitelist_external_dirs"] = set(result["whitelist_external_dirs"]) - if "allowlist_external_dirs" in result: - result["allowlist_external_dirs"] = set(result["allowlist_external_dirs"]) - if "allowlist_external_urls" in result: - result["allowlist_external_urls"] = set(result["allowlist_external_urls"]) + ignore_order_keys = ( + "components", + "allowlist_external_dirs", + "whitelist_external_dirs", + "allowlist_external_urls", + ) + config = hass.config.as_dict() - assert hass.config.as_dict() == result + for key in ignore_order_keys: + if key in result: + result[key] = set(result[key]) + config[key] = set(config[key]) + + assert result == config async def test_api_get_components( @@ -584,7 +588,7 @@ async def test_api_fire_event_context( ) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(test_value) == 1 assert test_value[0].context.user_id == refresh_token.user.id @@ -602,7 +606,7 @@ async def test_api_call_service_context( ) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(calls) == 1 assert calls[0].context.user_id == refresh_token.user.id @@ -618,7 +622,7 @@ async def test_api_set_state_context( headers={"authorization": f"Bearer {hass_access_token}"}, ) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) state = hass.states.get("light.kitchen") assert state.context.user_id == refresh_token.user.id @@ -684,6 +688,8 @@ async def test_get_entity_state_read_perm( ) -> None: """Test getting a state requires read permission.""" hass_admin_user.mock_policy({}) + hass_admin_user.groups = [] + assert hass_admin_user.is_admin is False resp = await mock_api_client.get("/api/states/light.test") assert resp.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 513c21f7ce5..714fe987bc8 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -691,6 +691,44 @@ async def test_zeroconf_ip_change(hass: HomeAssistant, mock_scan) -> None: assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" +async def test_zeroconf_ip_change_after_ip_conflict_with_ignored_entry( + hass: HomeAssistant, mock_scan +) -> None: + """Test that the config entry gets updated when the ip changes and reloads.""" + entry = MockConfigEntry( + domain="apple_tv", unique_id="mrpid", data={CONF_ADDRESS: "127.0.0.2"} + ) + ignored_entry = MockConfigEntry( + domain="apple_tv", + unique_id="unrelated", + data={CONF_ADDRESS: "127.0.0.2"}, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_async_setup.mock_calls) == 1 + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert ignored_entry.data[CONF_ADDRESS] == "127.0.0.2" + + async def test_zeroconf_ip_change_via_secondary_identifier( hass: HomeAssistant, mock_scan ) -> None: diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 97f80a33d1d..38c96871ed3 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -8,13 +8,15 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components import stt, tts, wake_word -from homeassistant.components.assist_pipeline import DOMAIN +from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, PipelineStorageCollection, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -288,7 +290,7 @@ async def init_supporting_components( ) -> bool: """Set up test config entry.""" await hass.config_entries.async_forward_entry_setups( - config_entry, [stt.DOMAIN, wake_word.DOMAIN] + config_entry, [Platform.STT, Platform.WAKE_WORD] ) return True @@ -297,7 +299,7 @@ async def init_supporting_components( ) -> bool: """Unload up test config entry.""" await hass.config_entries.async_unload_platforms( - config_entry, [stt.DOMAIN, wake_word.DOMAIN] + config_entry, [Platform.STT, Platform.WAKE_WORD] ) return True @@ -369,6 +371,79 @@ async def init_components(hass: HomeAssistant, init_supporting_components): assert await async_setup_component(hass, "assist_pipeline", {}) +@pytest.fixture +async def assist_device(hass: HomeAssistant, init_components) -> dr.DeviceEntry: + """Create an assist device.""" + config_entry = MockConfigEntry(domain="test_assist_device") + config_entry.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + name="Test Device", + config_entry_id=config_entry.entry_id, + identifiers={("test_assist_device", "test")}, + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.SELECT] + ) + 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.SELECT] + ) + return True + + async def async_setup_entry_select_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test select platform via config entry.""" + entities = [ + assist_select.AssistPipelineSelect( + hass, "test_assist_device", "test-prefix" + ), + assist_select.VadSensitivitySelect(hass, "test-prefix"), + ] + for ent in entities: + ent._attr_device_info = dr.DeviceInfo( + identifiers={("test_assist_device", "test")}, + ) + async_add_entities(entities) + + mock_integration( + hass, + MockModule( + "test_assist_device", + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + mock_platform( + hass, + "test_assist_device.select", + MockPlatform( + async_setup_entry=async_setup_entry_select_platform, + ), + ) + mock_platform(hass, "test_assist_device.config_flow") + + with mock_config_flow("test_assist_device", ConfigFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return device + + @pytest.fixture def pipeline_data(hass: HomeAssistant, init_components) -> PipelineData: """Return pipeline data.""" diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index c165675a6ff..a050b009a8d 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -771,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': 'Sorry, I am not aware of any area called are', }), }), }), diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index c4e750e1019..73c069ddd04 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.assist_pipeline import Pipeline from homeassistant.components.assist_pipeline.pipeline import ( + AssistDevice, PipelineData, PipelineStorageCollection, ) @@ -33,7 +34,7 @@ class SelectPlatform(MockPlatform): async_add_entities: AddEntitiesCallback, ) -> None: """Set up fake select platform.""" - pipeline_entity = AssistPipelineSelect(hass, "test") + pipeline_entity = AssistPipelineSelect(hass, "test-domain", "test-prefix") pipeline_entity._attr_device_info = DeviceInfo( identifiers={("test", "test")}, ) @@ -109,13 +110,15 @@ async def test_select_entity_registering_device( assert device is not None # Test device is registered - assert pipeline_data.pipeline_devices == {device.id} + assert pipeline_data.pipeline_devices == { + device.id: AssistDevice("test-domain", "test-prefix") + } await hass.config_entries.async_remove(init_select.entry_id) await hass.async_block_till_done() # Test device is removed - assert pipeline_data.pipeline_devices == set() + assert pipeline_data.pipeline_devices == {} async def test_select_entity_changing_pipelines( @@ -128,7 +131,7 @@ async def test_select_entity_changing_pipelines( """Test entity tracking pipeline changes.""" config_entry = init_select # nicer naming - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ @@ -143,13 +146,13 @@ async def test_select_entity_changing_pipelines( "select", "select_option", { - "entity_id": "select.assist_pipeline_test_pipeline", + "entity_id": "select.assist_pipeline_test_prefix_pipeline", "option": pipeline_2.name, }, blocking=True, ) - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == pipeline_2.name @@ -157,14 +160,14 @@ async def test_select_entity_changing_pipelines( assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == pipeline_2.name # Remove selected pipeline await pipeline_storage.async_delete_item(pipeline_2.id) - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 458320a9a90..3ea6be028c1 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2502,3 +2502,22 @@ async def test_pipeline_empty_tts_output( assert msg["event"]["type"] == "run-end" assert msg["event"]["data"] == snapshot events.append(msg["event"]) + + +async def test_pipeline_list_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + assist_device, +) -> None: + """Test list devices.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "assist_pipeline/device/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == [ + { + "device_id": assist_device.id, + "pipeline_entity": "select.test_assist_device_test_prefix_pipeline", + } + ] diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index e3122f1dfef..0ee90b111f5 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta -from pyasuswrt.asuswrt import AsusWrtError +from pyasuswrt.exceptions import AsusWrtError, AsusWrtNotAvailableInfoError import pytest from homeassistant.components import device_tracker, sensor @@ -226,6 +226,29 @@ async def test_loadavg_sensors_http(hass: HomeAssistant, connect_http) -> None: await _test_loadavg_sensors(hass, CONFIG_DATA_HTTP) +async def test_loadavg_sensors_unaivalable_http( + hass: HomeAssistant, connect_http +) -> None: + """Test load average sensors no available using http.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_LOAD_AVG) + config_entry.add_to_hass(hass) + + connect_http.return_value.async_get_loadavg.side_effect = ( + AsusWrtNotAvailableInfoError + ) + + # 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() + + # assert load average sensors not available + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg1") + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg5") + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg15") + + async def test_temperature_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: diff --git a/tests/components/atag/__init__.py b/tests/components/atag/__init__.py index c41632b9715..adea1e07be7 100644 --- a/tests/components/atag/__init__.py +++ b/tests/components/atag/__init__.py @@ -92,10 +92,11 @@ async def init_integration( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, + unique_id: str = UID, ) -> MockConfigEntry: """Set up the Atag integration in Home Assistant.""" mock_connection(aioclient_mock) - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT, unique_id=unique_id) entry.add_to_hass(hass) if not skip_setup: diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py index 8dc73741e90..69e2327c616 100644 --- a/tests/components/atag/test_config_flow.py +++ b/tests/components/atag/test_config_flow.py @@ -31,8 +31,7 @@ async def test_adding_second_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that only one Atag configuration is allowed.""" - entry = await init_integration(hass, aioclient_mock) - entry.unique_id = UID + await init_integration(hass, aioclient_mock, unique_id=UID) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index d71d22064fc..10b7eb86235 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -340,7 +340,7 @@ async def test_restored_state( ) # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) mock_restore_cache_with_extra_data( hass, [ @@ -351,8 +351,7 @@ async def test_restored_state( ], ) - august_entry = await _create_august_with_devices(hass, [lock_one]) - august_entry.add_to_hass(hass) + await _create_august_with_devices(hass, [lock_one]) await hass.async_block_till_done() diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index d156dce2154..3b5b375ed8b 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -8,10 +8,9 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.aurora_abb_powerone.const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DOMAIN, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index 92b448d8645..a330507c779 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -4,10 +4,9 @@ from unittest.mock import patch from homeassistant.components.aurora_abb_powerone.const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DOMAIN, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index a78682ced6d..4dbbf5f0048 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -8,12 +8,11 @@ from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_INTEGRATION_TITLE, DOMAIN, SCAN_INTERVAL, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 1430eca3a26..e16a721f5dc 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -25,6 +25,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 4088b1819fa..3926cd2f82b 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -8,7 +8,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError -from homeassistant.auth.models import Credentials +from homeassistant.auth.models import ( + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + TOKEN_TYPE_NORMAL, + Credentials, +) from homeassistant.components import auth from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -88,9 +92,7 @@ async def test_login_new_user_and_trying_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None assert tokens["ha_auth_provider"] == "insecure_example" # Use refresh token to get more tokens. @@ -106,9 +108,7 @@ async def test_login_new_user_and_trying_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() assert "refresh_token" not in tokens - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Test using access token to hit API. resp = await client.get("/api/") @@ -205,7 +205,7 @@ async def test_ws_current_user( """Test the current user command with Home Assistant creds.""" assert await async_setup_component(hass, "auth", {}) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) user = refresh_token.user client = await hass_ws_client(hass, hass_access_token) @@ -275,9 +275,7 @@ async def test_refresh_token_system_generated( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None async def test_refresh_token_different_client_id( @@ -323,9 +321,7 @@ async def test_refresh_token_different_client_id( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None async def test_refresh_token_checks_local_only_user( @@ -406,16 +402,14 @@ async def test_revoking_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Revoke refresh token resp = await client.post(url, data={**base_data, "token": refresh_token.token}) assert resp.status == HTTPStatus.OK # Old access token should be no longer valid - assert await hass.auth.async_validate_access_token(tokens["access_token"]) is None + assert hass.auth.async_validate_access_token(tokens["access_token"]) is None # Test that we no longer can create an access token resp = await client.post( @@ -454,7 +448,7 @@ async def test_ws_long_lived_access_token( long_lived_access_token = result["result"] assert long_lived_access_token is not None - refresh_token = await hass.auth.async_validate_access_token(long_lived_access_token) + refresh_token = hass.auth.async_validate_access_token(long_lived_access_token) assert refresh_token.client_id is None assert refresh_token.client_name == "GPS Logger" assert refresh_token.client_icon is None @@ -474,7 +468,7 @@ async def test_ws_refresh_tokens( assert result["success"], result assert len(result["result"]) == 1 token = result["result"][0] - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert token["id"] == refresh_token.id assert token["type"] == refresh_token.token_type assert token["client_id"] == refresh_token.client_id @@ -514,7 +508,7 @@ async def test_ws_delete_refresh_token( result = await ws_client.receive_json() assert result["success"], result - refresh_token = await hass.auth.async_get_refresh_token(refresh_token.id) + refresh_token = hass.auth.async_get_refresh_token(refresh_token.id) assert refresh_token is None @@ -573,26 +567,54 @@ async def test_ws_delete_all_refresh_tokens_error( ) in caplog.record_tuples for token in tokens: - refresh_token = await hass.auth.async_get_refresh_token(token["id"]) + refresh_token = hass.auth.async_get_refresh_token(token["id"]) assert refresh_token is None +@pytest.mark.parametrize( + ( + "delete_token_type", + "delete_current_token", + "expected_remaining_normal_tokens", + "expected_remaining_long_lived_tokens", + ), + [ + ({}, {}, 0, 0), + ({"token_type": TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN}, {}, 3, 0), + ({"token_type": TOKEN_TYPE_NORMAL}, {}, 0, 1), + ({"token_type": TOKEN_TYPE_NORMAL}, {"delete_current_token": False}, 1, 1), + ], +) async def test_ws_delete_all_refresh_tokens( hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials, hass_ws_client: WebSocketGenerator, hass_access_token: str, + delete_token_type: dict[str:str], + delete_current_token: dict[str:bool], + expected_remaining_normal_tokens: int, + expected_remaining_long_lived_tokens: int, ) -> None: - """Test deleting all refresh tokens.""" + """Test deleting all or some refresh tokens.""" assert await async_setup_component(hass, "auth", {"http": {}}) # one token already exists await hass.auth.async_create_refresh_token( hass_admin_user, CLIENT_ID, credential=hass_admin_credential ) + + # create a long lived token await hass.auth.async_create_refresh_token( - hass_admin_user, CLIENT_ID + "_1", credential=hass_admin_credential + hass_admin_user, + f"{CLIENT_ID}_LL", + client_name="client_ll", + credential=hass_admin_credential, + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + ) + + await hass.auth.async_create_refresh_token( + hass_admin_user, f"{CLIENT_ID}_1", credential=hass_admin_credential ) ws_client = await hass_ws_client(hass, hass_access_token) @@ -602,20 +624,35 @@ async def test_ws_delete_all_refresh_tokens( result = await ws_client.receive_json() assert result["success"], result - tokens = result["result"] - await ws_client.send_json( { "id": 6, "type": "auth/delete_all_refresh_tokens", + **delete_token_type, + **delete_current_token, } ) result = await ws_client.receive_json() assert result, result["success"] - for token in tokens: - refresh_token = await hass.auth.async_get_refresh_token(token["id"]) - assert refresh_token is None + + # We need to enumerate the user since we may remove the token + # that is used to authenticate the user which will prevent the websocket + # connection from working + remaining_tokens_by_type: dict[str, int] = { + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: 0, + TOKEN_TYPE_NORMAL: 0, + } + for refresh_token in hass_admin_user.refresh_tokens.values(): + remaining_tokens_by_type[refresh_token.token_type] += 1 + + assert ( + remaining_tokens_by_type[TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN] + == expected_remaining_long_lived_tokens + ) + assert ( + remaining_tokens_by_type[TOKEN_TYPE_NORMAL] == expected_remaining_normal_tokens + ) async def test_ws_sign_path( diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 27652ca2be4..c8b0261b79c 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from . import BASE_CONFIG, async_setup_auth @@ -26,22 +25,30 @@ _TRUSTED_NETWORKS_CONFIG = { @pytest.mark.parametrize( - ("provider_configs", "ip", "expected"), + ("ip", "preselect_remember_me"), + [ + ("192.168.1.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +@pytest.mark.parametrize( + ("provider_configs", "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}], + [{"type": "homeassistant"}], + [ + { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + } + ], ), ], ) @@ -49,8 +56,9 @@ async def test_fetch_auth_providers( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, provider_configs: list[dict[str, Any]], - ip: str | None, expected: list[dict[str, Any]], + ip: str, + preselect_remember_me: bool, ) -> None: """Test fetching auth providers.""" client = await async_setup_auth( @@ -58,73 +66,37 @@ async def test_fetch_auth_providers( ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - 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, + assert await resp.json() == { + "providers": expected, + "preselect_remember_me": preselect_remember_me, } + +@pytest.mark.parametrize( + ("ip", "expected"), + [ + ( + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + ), + ("::ffff:192.168.0.10", []), + ("1.2.3.4", []), + ("2001:db8::1", []), + ], +) +async def test_fetch_auth_providers_trusted_network( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + expected: list[dict[str, Any]], + ip: str, +) -> None: + """Test fetching auth providers.""" + client = await async_setup_auth( + hass, aiohttp_client, [_TRUSTED_NETWORKS_CONFIG], custom_ip=ip + ) 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, - ) + assert (await resp.json())["providers"] == expected async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6bb1b89259a..3a8c12f735a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1202,7 +1202,7 @@ async def test_initial_value_off(hass: HomeAssistant) -> None: async def test_initial_value_on(hass: HomeAssistant) -> None: """Test initial value on.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") assert await async_setup_component( @@ -1231,7 +1231,7 @@ async def test_initial_value_on(hass: HomeAssistant) -> None: async def test_initial_value_off_but_restore_on(hass: HomeAssistant) -> None: """Test initial value off and restored state is turned on.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") mock_restore_cache(hass, (State("automation.hello", STATE_ON),)) @@ -1328,7 +1328,7 @@ async def test_automation_is_on_if_no_initial_state_or_restore( async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: """Test if automation is not trigger on bootstrap.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") assert await async_setup_component( @@ -2460,7 +2460,7 @@ async def test_recursive_automation_starting_script( await asyncio.wait_for(script_done_event.wait(), 10) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 10) @@ -2521,7 +2521,7 @@ async def test_recursive_automation( await asyncio.wait_for(service_called.wait(), 1) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 6ba0661ae55..1ec85f60b5d 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -51,7 +51,10 @@ async def test_spa_defaults( assert state assert ( state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 @@ -71,7 +74,10 @@ async def test_spa_defaults_fake_tscale( assert state assert ( state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 @@ -174,6 +180,8 @@ async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None: == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 diff --git a/tests/components/bang_olufsen/__init__.py b/tests/components/bang_olufsen/__init__.py new file mode 100644 index 00000000000..150fc7c846d --- /dev/null +++ b/tests/components/bang_olufsen/__init__.py @@ -0,0 +1 @@ +"""Tests for the bang_olufsen integration.""" diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py new file mode 100644 index 00000000000..8c212ef16be --- /dev/null +++ b/tests/components/bang_olufsen/conftest.py @@ -0,0 +1,70 @@ +"""Test fixtures for bang_olufsen.""" + +from unittest.mock import AsyncMock, patch + +from mozart_api.models import BeolinkPeer +import pytest + +from homeassistant.components.bang_olufsen.const import DOMAIN + +from .const import ( + TEST_DATA_CREATE_ENTRY, + TEST_FRIENDLY_NAME, + TEST_JID_1, + TEST_NAME, + TEST_SERIAL_NUMBER, +) + +from tests.common import MockConfigEntry + + +class MockMozartClient: + """Class for mocking MozartClient objects and methods.""" + + async def __aenter__(self): + """Mock async context entry.""" + + async def __aexit__(self, exc_type, exc, tb): + """Mock async context exit.""" + + # API call results + get_beolink_self_result = BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME, jid=TEST_JID_1 + ) + + # API endpoints + get_beolink_self = AsyncMock() + get_beolink_self.return_value = get_beolink_self_result + + +@pytest.fixture +def mock_config_entry(): + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SERIAL_NUMBER, + data=TEST_DATA_CREATE_ENTRY, + title=TEST_NAME, + ) + + +@pytest.fixture +def mock_client(): + """Mock MozartClient.""" + + client = MockMozartClient() + + with patch("mozart_api.mozart_client.MozartClient", return_value=client): + yield client + + # Reset mocked API call counts and side effects + client.get_beolink_self.reset_mock(side_effect=True) + + +@pytest.fixture +def mock_setup_entry(): + """Mock successful setup entry.""" + with patch( + "homeassistant.components.bang_olufsen.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py new file mode 100644 index 00000000000..1b13e1b3412 --- /dev/null +++ b/tests/components/bang_olufsen/const.py @@ -0,0 +1,83 @@ +"""Constants used for testing the bang_olufsen integration.""" + + +from ipaddress import IPv4Address, IPv6Address + +from homeassistant.components.bang_olufsen.const import ( + ATTR_FRIENDLY_NAME, + ATTR_ITEM_NUMBER, + ATTR_SERIAL_NUMBER, + ATTR_TYPE_NUMBER, + CONF_BEOLINK_JID, +) +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME + +TEST_HOST = "192.168.0.1" +TEST_HOST_INVALID = "192.168.0" +TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888" +TEST_MODEL_BALANCE = "Beosound Balance" +TEST_MODEL_THEATRE = "Beosound Theatre" +TEST_MODEL_LEVEL = "Beosound Level" +TEST_SERIAL_NUMBER = "11111111" +TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" +TEST_FRIENDLY_NAME = "Living room Balance" +TEST_TYPE_NUMBER = "1111" +TEST_ITEM_NUMBER = "1111111" +TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" + + +TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." +TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." +TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF + +TEST_DATA_USER = {CONF_HOST: TEST_HOST, CONF_MODEL: TEST_MODEL_BALANCE} +TEST_DATA_USER_INVALID = {CONF_HOST: TEST_HOST_INVALID, CONF_MODEL: TEST_MODEL_BALANCE} + + +TEST_DATA_CREATE_ENTRY = { + CONF_HOST: TEST_HOST, + CONF_MODEL: TEST_MODEL_BALANCE, + CONF_BEOLINK_JID: TEST_JID_1, + CONF_NAME: TEST_NAME, +} + +TEST_DATA_ZEROCONF = ZeroconfServiceInfo( + ip_address=IPv4Address(TEST_HOST), + ip_addresses=[IPv4Address(TEST_HOST)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ + ATTR_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER, + ATTR_TYPE_NUMBER: TEST_TYPE_NUMBER, + ATTR_ITEM_NUMBER: TEST_ITEM_NUMBER, + }, +) + +TEST_DATA_ZEROCONF_NOT_MOZART = ZeroconfServiceInfo( + ip_address=IPv4Address(TEST_HOST), + ip_addresses=[IPv4Address(TEST_HOST)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER}, +) + +TEST_DATA_ZEROCONF_IPV6 = ZeroconfServiceInfo( + ip_address=IPv6Address(TEST_HOST_IPV6), + ip_addresses=[IPv6Address(TEST_HOST_IPV6)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ + ATTR_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER, + ATTR_TYPE_NUMBER: TEST_TYPE_NUMBER, + ATTR_ITEM_NUMBER: TEST_ITEM_NUMBER, + }, +) diff --git a/tests/components/bang_olufsen/test_config_flow.py b/tests/components/bang_olufsen/test_config_flow.py new file mode 100644 index 00000000000..dd42c4c5c8c --- /dev/null +++ b/tests/components/bang_olufsen/test_config_flow.py @@ -0,0 +1,163 @@ +"""Test the bang_olufsen config_flow.""" + + +from unittest.mock import Mock + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +import pytest + +from homeassistant.components.bang_olufsen.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MockMozartClient +from .const import ( + TEST_DATA_CREATE_ENTRY, + TEST_DATA_USER, + TEST_DATA_USER_INVALID, + TEST_DATA_ZEROCONF, + TEST_DATA_ZEROCONF_IPV6, + TEST_DATA_ZEROCONF_NOT_MOZART, +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_config_flow_timeout_error( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle timeout_error.""" + mock_client.get_beolink_self.side_effect = TimeoutError() + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "timeout_error"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_client_connector_error( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle client_connector_error.""" + mock_client.get_beolink_self.side_effect = ClientConnectorError(Mock(), Mock()) + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "client_connector_error"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_invalid_ip(hass: HomeAssistant) -> None: + """Test we handle invalid_ip.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER_INVALID, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "invalid_ip"} + + +async def test_config_flow_api_exception( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle api_exception.""" + mock_client.get_beolink_self.side_effect = ApiException() + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "api_exception"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow(hass: HomeAssistant, mock_client: MockMozartClient) -> None: + """Test config flow.""" + + result_init = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=None, + ) + + assert result_init["type"] == FlowResultType.FORM + assert result_init["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + flow_id=result_init["flow_id"], + user_input=TEST_DATA_USER, + ) + + assert result_user["type"] == FlowResultType.CREATE_ENTRY + assert result_user["data"] == TEST_DATA_CREATE_ENTRY + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_zeroconf( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test zeroconf discovery.""" + + result_zeroconf = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF, + ) + + assert result_zeroconf["type"] == FlowResultType.FORM + assert result_zeroconf["step_id"] == "zeroconf_confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + flow_id=result_zeroconf["flow_id"], + user_input=TEST_DATA_USER, + ) + + assert result_confirm["type"] == FlowResultType.CREATE_ENTRY + assert result_confirm["data"] == TEST_DATA_CREATE_ENTRY + + assert mock_client.get_beolink_self.call_count == 0 + + +async def test_config_flow_zeroconf_not_mozart_device(hass: HomeAssistant) -> None: + """Test zeroconf discovery of invalid device.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF_NOT_MOZART, + ) + + assert result_user["type"] == FlowResultType.ABORT + assert result_user["reason"] == "not_mozart_device" + + +async def test_config_flow_zeroconf_ipv6(hass: HomeAssistant) -> None: + """Test zeroconf discovery with IPv6 IP address.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF_IPV6, + ) + + assert result_user["type"] == FlowResultType.ABORT + assert result_user["reason"] == "ipv6_address" diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index d7deaf39bd9..d15d35e1c08 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -45,6 +45,7 @@ def camera() -> MagicMock: mock_blink_camera.motion_detected = False mock_blink_camera.wifi_strength = 2.1 mock_blink_camera.camera_type = "lotus" + mock_blink_camera.version = "123" mock_blink_camera.attributes = CAMERA_ATTRIBUTES return mock_blink_camera diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index ada38451754..e5ce3c83fb7 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Blink config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch from blinkpy.auth import LoginError from blinkpy.blinkpy import BlinkSetupError @@ -8,8 +8,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.blink import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -258,46 +256,3 @@ async def test_reauth_shows_user_step(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "blink@example.com", "password": "example"}, - options={}, - entry_id=1, - version=3, - ) - config_entry.add_to_hass(hass) - - mock_auth = AsyncMock( - startup=Mock(return_value=True), check_key_required=Mock(return_value=False) - ) - mock_blink = AsyncMock(cameras=Mock(), sync=Mock()) - - with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( - "homeassistant.components.blink.Blink", return_value=mock_blink - ): - 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, context={"show_advanced_options": False} - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "simple_options" - - with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( - "homeassistant.components.blink.Blink", return_value=mock_blink - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"scan_interval": 5}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == {"scan_interval": 5} - await hass.async_block_till_done() - - assert mock_blink.refresh_rate == 5 diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py index c510aeada4f..057701235ad 100644 --- a/tests/components/blue_current/test_config_flow.py +++ b/tests/components/blue_current/test_config_flow.py @@ -12,6 +12,9 @@ from homeassistant.components.blue_current.config_flow import ( 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: @@ -30,8 +33,12 @@ async def test_user(hass: HomeAssistant) -> None: ) assert result["errors"] == {} - with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( - "bluecurrent_api.Client.get_email", return_value="test@email.com" + 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, @@ -59,9 +66,9 @@ async def test_user(hass: HomeAssistant) -> None: ], ) async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: - """Test user initialized flow with invalid username.""" + """Test bluecurrent api errors during configuration flow.""" with patch( - "bluecurrent_api.Client.validate_api_token", + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", side_effect=error, ): result = await hass.config_entries.flow.async_init( @@ -71,8 +78,12 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - ) assert result["errors"]["base"] == message - with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( - "bluecurrent_api.Client.get_email", return_value="test@email.com" + 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, @@ -87,3 +98,52 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - 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 index fe40f58077f..14bd055cd45 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -4,13 +4,22 @@ from datetime import timedelta from unittest.mock import patch from bluecurrent_api.client import Client -from bluecurrent_api.exceptions import RequestLimitReached, WebsocketError +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 ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + IntegrationError, +) from . import init_integration @@ -29,12 +38,21 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: assert hass.data[DOMAIN] == {} -async def test_config_not_ready(hass: HomeAssistant) -> None: - """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketError.""" +@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( - "bluecurrent_api.Client.connect", - side_effect=WebsocketError, - ), pytest.raises(ConfigEntryNotReady): + "homeassistant.components.blue_current.Client.connect", + side_effect=api_error, + ), pytest.raises(config_error): config_entry = MockConfigEntry( domain=DOMAIN, entry_id="uuid", @@ -143,14 +161,15 @@ async def test_start_loop(hass: HomeAssistant) -> None: connector = Connector(hass, config_entry, Client) with patch( - "bluecurrent_api.Client.start_loop", + "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( - "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + "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) @@ -159,11 +178,7 @@ async def test_start_loop(hass: HomeAssistant) -> None: async def test_reconnect(hass: HomeAssistant) -> None: """Tests reconnect.""" - with patch("bluecurrent_api.Client.connect"), patch( - "bluecurrent_api.Client.connect", side_effect=WebsocketError - ), patch( - "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) - ), patch( + with patch( "homeassistant.components.blue_current.async_call_later" ) as test_async_call_later: config_entry = MockConfigEntry( @@ -174,12 +189,33 @@ async def test_reconnect(hass: HomeAssistant) -> None: ) connector = Connector(hass, config_entry, Client) - await connector.reconnect() + + 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("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + 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 - ) + + 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/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 5ad4b5a6c31..f4616abf8e5 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,6 +1,7 @@ """Tests for the Bluetooth integration.""" +from collections.abc import Iterable from contextlib import contextmanager import itertools import time @@ -295,7 +296,20 @@ class MockBleakClient(BleakClient): return True -class FakeScanner(BaseHaScanner): +class FakeScannerMixin: + def get_discovered_device_advertisement_data( + self, address: str + ) -> tuple[BLEDevice, AdvertisementData] | None: + """Return the advertisement data for a discovered device.""" + return self.discovered_devices_and_advertisement_data.get(address) + + @property + def discovered_addresses(self) -> Iterable[str]: + """Return an iterable of discovered devices.""" + return self.discovered_devices_and_advertisement_data + + +class FakeScanner(FakeScannerMixin, BaseHaScanner): """Fake scanner.""" @property diff --git a/tests/components/bluetooth/test_active_update_coordinator.py b/tests/components/bluetooth/test_active_update_coordinator.py index 2686138d724..83fee1456cd 100644 --- a/tests/components/bluetooth/test_active_update_coordinator.py +++ b/tests/components/bluetooth/test_active_update_coordinator.py @@ -439,7 +439,7 @@ async def test_no_polling_after_stop_event( assert needs_poll_calls == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert needs_poll_calls == 1 diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index fba86223a2d..00562a20daf 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -436,7 +436,7 @@ async def test_no_polling_after_stop_event( assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert needs_poll_calls == 1 diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index a42752dcfc7..bc65874b0cc 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -8,7 +8,6 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, - BaseHaScanner, HaBluetoothConnector, async_scanner_by_source, async_scanner_devices_by_address, @@ -124,7 +123,7 @@ async def test_async_scanner_devices_by_address_non_connectable( rssi=-100, ) - class FakeStaticScanner(BaseHaScanner): + class FakeStaticScanner(FakeScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index a8e693c3f99..eae5f6507ac 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,17 +3,18 @@ from unittest.mock import ANY, MagicMock, patch 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 ( MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, + HaScanner, ) from homeassistant.core import HomeAssistant from . import ( + FakeScannerMixin, MockBleakClient, _get_manager, generate_advertisement_data, @@ -26,7 +27,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -class FakeHaScanner(HaScanner): +class FakeHaScanner(FakeScannerMixin, HaScanner): """Fake HaScanner.""" @property @@ -77,23 +78,16 @@ async def test_diagnostics( } }, ): - entry1 = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry1.add_to_hass(hass) - entry2 = MockConfigEntry( domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:02" ) entry2.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry1.entry_id) - await hass.async_block_till_done() assert await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry2) + expected = { "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -179,33 +173,6 @@ async def test_diagnostics( "start_time": ANY, "type": "HaScanner", }, - { - "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, - } - ], - "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": "FakeHaScanner", - }, { "adapter": "hci1", "discovered_devices_and_advertisement_data": [ @@ -241,6 +208,12 @@ async def test_diagnostics( }, }, } + diag_scanners = diag["manager"].pop("scanners") + expected_scanners = expected["manager"].pop("scanners") + assert diag == expected + assert sorted(diag_scanners, key=lambda x: x["name"]) == sorted( + expected_scanners, key=lambda x: x["name"] + ) @patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) @@ -447,13 +420,7 @@ async def test_diagnostics_remote_adapter( "homeassistant.components.bluetooth.diagnostics.get_dbus_managed_objects", return_value={}, ): - entry1 = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry1.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry1.entry_id) - await hass.async_block_till_done() + entry1 = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) @@ -466,7 +433,7 @@ async def test_diagnostics_remote_adapter( diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { + expected = { "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -489,7 +456,7 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, @@ -567,33 +534,6 @@ async def test_diagnostics_remote_adapter( "start_time": ANY, "type": "HaScanner", }, - { - "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, - } - ], - "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": "FakeHaScanner", - }, { "connectable": True, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, @@ -640,5 +580,12 @@ async def test_diagnostics_remote_adapter( }, } + diag_scanners = diag["manager"].pop("scanners") + expected_scanners = expected["manager"].pop("scanners") + assert diag == expected + assert sorted(diag_scanners, key=lambda x: x["name"]) == sorted( + expected_scanners, key=lambda x: x["name"] + ) + cancel() unsetup() diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 1659b989af0..35ee073bc87 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import time -from unittest.mock import ANY, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -376,6 +376,56 @@ async def test_discovery_match_by_service_uuid( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" +@patch.object( + bluetooth, + "async_get_bluetooth", + return_value=[ + { + "domain": "sensorpush", + "local_name": "s", + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9", + } + ], +) +async def test_discovery_match_by_service_uuid_and_short_local_name( + mock_async_get_bluetooth: AsyncMock, + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test bluetooth discovery match by service_uuid and short local name.""" + entry = MockConfigEntry(domain="bluetooth", unique_id="00:00:00:00:00:01") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + await async_setup_with_default_adapter(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") + wrong_adv = generate_advertisement_data(local_name="s", service_uuids=[]) + + inject_advertisement(hass, wrong_device, wrong_adv) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + ht1_device = generate_ble_device("44:44:33:11:23:45", "s") + ht1_adv = generate_advertisement_data( + local_name="s", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090aa9"] + ) + + inject_advertisement(hass, ht1_device, ht1_adv) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "sensorpush" + + def _domains_from_mock_config_flow(mock_config_flow: Mock) -> list[str]: """Get all the domains that were passed to async_init except bluetooth.""" return [call[1][0] for call in mock_config_flow.mock_calls if call[1][0] != DOMAIN] @@ -2016,14 +2066,6 @@ async def test_register_callback_by_local_name_overly_broad( ): await async_setup_with_default_adapter(hass) - with pytest.raises(ValueError): - bluetooth.async_register_callback( - hass, - _fake_subscriber, - {LOCAL_NAME: "a"}, - BluetoothScanningMode.ACTIVE, - ) - with pytest.raises(ValueError): bluetooth.async_register_callback( hass, diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 9b513ed2197..680d7c2e798 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.core import HomeAssistant from . import ( + FakeScannerMixin, MockBleakClient, _get_manager, generate_advertisement_data, @@ -79,7 +80,7 @@ async def test_wrapped_bleak_client_local_adapter_only( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaScanner): + class FakeScanner(FakeScannerMixin, BaseHaScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -155,7 +156,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -266,7 +267,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -331,7 +332,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -434,7 +435,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -546,7 +547,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 345c4b62b7e..5c7c4e39083 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -553,7 +553,7 @@ async def test_no_updates_once_stopping( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) assert len(all_events) == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) # We should stop processing events once hass is stopping inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py index 100a133ae4d..d61b4e06560 100644 --- a/tests/components/bond/test_entity.py +++ b/tests/components/bond/test_entity.py @@ -206,7 +206,7 @@ async def test_polling_stops_at_the_stop_event(hass: HomeAssistant) -> None: assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() with patch_bond_device_state(return_value={"power": 1, "speed": 1}): diff --git a/tests/components/bring/__init__.py b/tests/components/bring/__init__.py new file mode 100644 index 00000000000..1b13247f52e --- /dev/null +++ b/tests/components/bring/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bring! integration.""" diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py new file mode 100644 index 00000000000..81a76c9ee3e --- /dev/null +++ b/tests/components/bring/conftest.py @@ -0,0 +1,49 @@ +"""Common fixtures for the Bring! tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.bring import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + +EMAIL = "test-email" +PASSWORD = "test-password" + +UUID = "00000000-00000000-00000000-00000000" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bring.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_bring_client() -> Generator[AsyncMock, None, None]: + """Mock a Bring client.""" + with patch( + "homeassistant.components.bring.Bring", + autospec=True, + ) as mock_client, patch( + "homeassistant.components.bring.config_flow.Bring", + new=mock_client, + ): + client = mock_client.return_value + client.uuid = UUID + client.loginAsync.return_value = True + client.loadListsAsync.return_value = {"lists": []} + yield client + + +@pytest.fixture(name="bring_config_entry") +def mock_bring_config_entry() -> MockConfigEntry: + """Mock bring configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD}, unique_id=UUID + ) diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py new file mode 100644 index 00000000000..531554d584e --- /dev/null +++ b/tests/components/bring/test_config_flow.py @@ -0,0 +1,113 @@ +"""Test the Bring! config flow.""" +from unittest.mock import AsyncMock + +import pytest +from python_bring_api.exceptions import ( + BringAuthException, + BringParseException, + BringRequestException, +) + +from homeassistant import config_entries +from homeassistant.components.bring.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import EMAIL, PASSWORD + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, +} + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bring_client: 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"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DATA_STEP["email"] + assert result["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, mock_bring_client: AsyncMock, raise_error, text_error +) -> None: + """Test unknown errors.""" + mock_bring_client.loginAsync.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_bring_client.loginAsync.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == MOCK_DATA_STEP["email"] + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, +) -> None: + """Test we abort user data set when entry is already configured.""" + + bring_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py new file mode 100644 index 00000000000..59628fa59b7 --- /dev/null +++ b/tests/components/bring/test_init.py @@ -0,0 +1,63 @@ +"""Unit tests for the bring integration.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.bring import ( + BringAuthException, + BringParseException, + BringRequestException, +) +from homeassistant.components.bring.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the bring integration.""" + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_load_unload( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + await setup_integration(hass, bring_config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert bring_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(bring_config_entry.entry_id) + assert bring_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_failure( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + status: ConfigEntryState, + exception: Exception, + bring_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + mock_bring_client.loginAsync.side_effect = exception + await setup_integration(hass, bring_config_entry) + assert bring_config_entry.state == status diff --git a/tests/components/bthome/test_event.py b/tests/components/bthome/test_event.py new file mode 100644 index 00000000000..f6cf3fd49c7 --- /dev/null +++ b/tests/components/bthome/test_event.py @@ -0,0 +1,116 @@ +"""Test the BTHome events.""" + +import pytest + +from homeassistant.components.bthome.const import DOMAIN +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import make_bthome_v2_adv + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + BluetoothServiceInfoBleak, + inject_bluetooth_service_info, +) + + +@pytest.mark.parametrize( + ("mac_address", "advertisement", "bind_key", "result"), + [ + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x3A\x00\x3A\x01\x3A\x03", + ), + None, + [ + { + "entity": "event.test_device_18b2_button_2", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button 2", + ATTR_EVENT_TYPE: "press", + }, + { + "entity": "event.test_device_18b2_button_3", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button 3", + ATTR_EVENT_TYPE: "triple_press", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x3A\x04", + ), + None, + [ + { + "entity": "event.test_device_18b2_button", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button", + ATTR_EVENT_TYPE: "long_press", + } + ], + ), + ], +) +async def test_v2_events( + hass: HomeAssistant, + mac_address: str, + advertisement: BluetoothServiceInfoBleak, + bind_key: str | None, + result: list[dict[str, str]], +) -> None: + """Test the different BTHome V2 events.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Ensure entities are restored + for meas in result: + state = hass.states.get(meas["entity"]) + assert state != STATE_UNAVAILABLE + + # Now inject again + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 0b6e7a42cfb..481520f0434 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -819,7 +819,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_volume", "friendly_name": "Test Device 18B2 Volume", "unit_of_measurement": "L", - "state_class": "measurement", + "state_class": "total", "expected_state": "2215.1", }, ], @@ -836,7 +836,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_volume", "friendly_name": "Test Device 18B2 Volume", "unit_of_measurement": "mL", - "state_class": "measurement", + "state_class": "total", "expected_state": "34780", }, ], @@ -869,7 +869,7 @@ async def test_v1_sensors( { "sensor_entity": "sensor.test_device_18b2_timestamp", "friendly_name": "Test Device 18B2 Timestamp", - "state_class": "measurement", + "state_class": None, "expected_state": "2023-05-14T19:41:17+00:00", }, ], @@ -972,6 +972,23 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x55\x87\x56\x2a\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_volume_storage", + "friendly_name": "Test Device 18B2 Volume Storage", + "unit_of_measurement": "L", + "state_class": "measurement", + "expected_state": "19551.879", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 24f893578ce..acf7bd39e10 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -1,7 +1,9 @@ """The tests for the Button component.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.button import ( @@ -12,7 +14,12 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -51,6 +58,7 @@ async def test_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, + freezer: FrozenDateTimeFactory, ) -> None: """Test we integration.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -62,17 +70,31 @@ async def test_custom_integration( assert hass.states.get("button.button_1").state == STATE_UNKNOWN now = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow", return_value=now): - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.button_1"}, - blocking=True, - ) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.button_1"}, + blocking=True, + ) assert hass.states.get("button.button_1").state == now.isoformat() assert "The button has been pressed" in caplog.text + now_isoformat = dt_util.utcnow().isoformat() + assert hass.states.get("button.button_1").state == now_isoformat + + new_time = dt_util.utcnow() + timedelta(weeks=1) + freezer.move_to(new_time) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.button_1"}, + blocking=True, + ) + + new_time_isoformat = new_time.isoformat() + assert hass.states.get("button.button_1").state == new_time_isoformat + async def test_restore_state( hass: HomeAssistant, enable_custom_integrations: None @@ -89,6 +111,21 @@ async def test_restore_state( assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00" +async def test_restore_state_does_not_restore_unavailable( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we restore state integration except for unavailable.""" + mock_restore_cache(hass, (State("button.button_1", STATE_UNAVAILABLE),)) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("button.button_1").state == STATE_UNKNOWN + + class MockFlow(ConfigFlow): """Test flow.""" diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 5d506d67c6f..f42cc6fd508 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -1,14 +1,29 @@ """Test fixtures for calendar sensor platforms.""" +from collections.abc import Generator +import datetime +import secrets +from typing import Any +from unittest.mock import AsyncMock + import pytest +from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) +TEST_DOMAIN = "test" @pytest.fixture @@ -17,3 +32,161 @@ def set_time_zone(hass: HomeAssistant) -> None: # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round hass.config.set_time_zone("America/Regina") + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +class MockCalendarEntity(CalendarEntity): + """Test Calendar entity.""" + + _attr_has_entity_name = True + + def __init__(self, name: str, events: list[CalendarEvent] | None = None) -> None: + """Initialize entity.""" + self._attr_name = name.capitalize() + self._events = events or [] + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return self._events[0] if self._events else None + + def create_event( + self, + start: datetime.datetime, + end: datetime.datetime, + summary: str | None = None, + description: str | None = None, + location: str | None = None, + ) -> dict[str, Any]: + """Create a new fake event, used by tests.""" + event = CalendarEvent( + start=start, + end=end, + summary=summary if summary else f"Event {secrets.token_hex(16)}", + description=description, + location=location, + ) + self._events.append(event) + return event.as_dict() + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + assert start_date < end_date + events = [] + for event in self._events: + if event.start_datetime_local >= end_date: + continue + if event.end_datetime_local < start_date: + continue + events.append(event) + return events + + +@pytest.fixture +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_setup_integration(hass: HomeAssistant, config_flow_fixture: None) -> 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.CALENDAR] + ) + 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[CalendarEntity], +) -> MockConfigEntry: + """Create a calendar 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 + + +@pytest.fixture(name="test_entities") +def mock_test_entities() -> list[MockCalendarEntity]: + """Fixture to create fake entities used in the test.""" + half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) + entity1 = MockCalendarEntity( + "Calendar 1", + [ + CalendarEvent( + start=half_hour_from_now, + end=half_hour_from_now + datetime.timedelta(minutes=60), + summary="Future Event", + description="Future Description", + location="Future Location", + ) + ], + ) + entity1.async_get_events = AsyncMock(wraps=entity1.async_get_events) + + middle_of_event = dt_util.now() - datetime.timedelta(minutes=30) + entity2 = MockCalendarEntity( + "Calendar 2", + [ + CalendarEvent( + start=middle_of_event, + end=middle_of_event + datetime.timedelta(minutes=60), + summary="Current Event", + ) + ], + ) + entity2.async_get_events = AsyncMock(wraps=entity2.async_get_events) + + return [entity1, entity2] diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr index 67e8839f7a5..fe23c5dbac9 100644 --- a/tests/components/calendar/snapshots/test_init.ambr +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-get_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-00:15:00-get_events] dict({ 'calendar.calendar_1': dict({ 'events': list([ @@ -7,59 +7,59 @@ }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-00:15:00-list_events] dict({ 'events': list([ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-get_events] +# name: test_list_events_service_duration[frozen_time-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', + 'end': '2023-10-19T09:20:05-06:00', 'location': 'Future Location', - 'start': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T08:20:05-06:00', 'summary': 'Future Event', }), ]), }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-01:00:00-list_events] dict({ 'events': list([ dict({ 'description': 'Future Description', - 'end': '2023-10-19T08:20:05-07:00', + 'end': '2023-10-19T09:20:05-06:00', 'location': 'Future Location', - 'start': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T08:20:05-06:00', 'summary': 'Future Event', }), ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-get_events] +# name: test_list_events_service_duration[frozen_time-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', + 'end': '2023-10-19T08:20:05-06:00', + 'start': '2023-10-19T07:20:05-06:00', 'summary': 'Current Event', }), ]), }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_2-00:15:00-list_events] dict({ 'events': list([ dict({ - 'end': '2023-10-19T07:20:05-07:00', - 'start': '2023-10-19T06:20:05-07:00', + 'end': '2023-10-19T08:20:05-06:00', + 'start': '2023-10-19T07:20:05-06:00', 'summary': 'Current Event', }), ]), diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 25804287172..52d5855271d 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -1,17 +1,16 @@ """The tests for the calendar component.""" from __future__ import annotations +from collections.abc import Generator from datetime import timedelta from http import HTTPStatus from typing import Any -from unittest.mock import patch from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.bootstrap import async_setup_component from homeassistant.components.calendar import ( DOMAIN, LEGACY_SERVICE_LIST_EVENTS, @@ -22,15 +21,46 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util +from .conftest import TEST_DOMAIN, MockCalendarEntity, create_mock_platform + from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(name="frozen_time") +def mock_frozen_time() -> None: + """Fixture to set a frozen time used in tests. + + This is needed so that it can run before other fixtures. + """ + return None + + +@pytest.fixture(autouse=True) +def mock_set_frozen_time(frozen_time: Any) -> Generator[None, None, None]: + """Fixture to freeze time that also can work for other fixtures.""" + if not frozen_time: + yield + else: + with freeze_time(frozen_time): + yield + + +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( + hass: HomeAssistant, + set_time_zone: Any, + frozen_time: Any, + mock_setup_integration: Any, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture to setup platforms used in the test and fixtures are set up in the right order.""" + await create_mock_platform(hass, test_entities) + + async def test_events_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=1) @@ -46,40 +76,34 @@ async def test_events_http_api_missing_fields( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() response = await client.get("/api/calendars/calendar.calendar_2") assert response.status == HTTPStatus.BAD_REQUEST async def test_events_http_api_error( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + test_entities: list[MockCalendarEntity], ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=1) - with patch( - "homeassistant.components.demo.calendar.DemoCalendar.async_get_events", - side_effect=HomeAssistantError("Failure"), - ): - response = await client.get( - f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" - ) - assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert await response.json() == {"message": "Error reading events: Failure"} + test_entities[0].async_get_events.side_effect = HomeAssistantError("Failure") + + response = await client.get( + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert await response.json() == {"message": "Error reading events: Failure"} async def test_events_http_api_dates_wrong_order( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=-1) @@ -93,8 +117,6 @@ async def test_calendars_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() response = await client.get("/api/calendars") assert response.status == HTTPStatus.OK @@ -180,8 +202,6 @@ async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload, code ) -> None: """Test unsupported websocket command.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_ws_client(hass) await client.send_json( { @@ -198,9 +218,6 @@ async def test_unsupported_websocket( async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: """Test unsupported service call.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(HomeAssistantError, match="does not support this service"): await hass.services.async_call( DOMAIN, @@ -377,9 +394,6 @@ async def test_create_event_service_invalid_params( ) -> None: """Test creating an event using the create_event service.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(expected_error, match=error_match): await hass.services.async_call( "calendar", @@ -393,7 +407,9 @@ async def test_create_event_service_invalid_params( ) -@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) @pytest.mark.parametrize( ("service", "expected"), [ @@ -439,7 +455,6 @@ async def test_create_event_service_invalid_params( ) async def test_list_events_service( hass: HomeAssistant, - set_time_zone: None, start_time: str, end_time: str, service: str, @@ -451,9 +466,6 @@ async def test_list_events_service( string output values. """ - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - response = await hass.services.async_call( DOMAIN, service, @@ -487,7 +499,7 @@ async def test_list_events_service( ("calendar.calendar_2", "00:15:00"), ], ) -@pytest.mark.freeze_time("2023-10-19 13:50:05") +@pytest.mark.parametrize("frozen_time", ["2023-10-19 13:50:05"], ids=["frozen_time"]) async def test_list_events_service_duration( hass: HomeAssistant, entity: str, @@ -496,9 +508,6 @@ async def test_list_events_service_duration( snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - response = await hass.services.async_call( DOMAIN, service, @@ -514,9 +523,6 @@ async def test_list_events_service_duration( async def test_list_events_positive_duration(hass: HomeAssistant) -> None: """Test listing events requires a positive duration.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(vol.Invalid, match="should be positive"): await hass.services.async_call( DOMAIN, @@ -532,9 +538,6 @@ async def test_list_events_positive_duration(hass: HomeAssistant) -> None: async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: """Test listing events specifying fields that are exclusive.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - end = dt_util.now() + timedelta(days=1) with pytest.raises(vol.Invalid, match="at most one of"): @@ -553,9 +556,6 @@ async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: async def test_list_events_missing_fields(hass: HomeAssistant) -> None: """Test listing events missing some required fields.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(vol.Invalid, match="at least one of"): await hass.services.async_call( DOMAIN, @@ -575,9 +575,6 @@ async def test_issue_deprecated_service_calendar_list_events( ) -> 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, @@ -594,7 +591,7 @@ async def test_issue_deprecated_service_calendar_list_events( "calendar", "deprecated_service_calendar_list_events" ) assert issue - assert issue.issue_domain == "demo" + assert issue.issue_domain == TEST_DOMAIN assert issue.issue_id == "deprecated_service_calendar_list_events" assert issue.translation_key == "deprecated_service_calendar_list_events" diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index c529789b596..441d757aa4e 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -1,41 +1,36 @@ """The tests for calendar recorder.""" from datetime import timedelta -from unittest.mock import patch +from typing import Any import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME, Platform +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .conftest import MockCalendarEntity, create_mock_platform + from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done @pytest.fixture(autouse=True) -async def setup_homeassistant(): - """Override the fixture in calendar.conftest.""" +async def mock_setup_dependencies( + recorder_mock: Recorder, + hass: HomeAssistant, + set_time_zone: Any, + mock_setup_integration: None, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture that ensures the recorder is setup in the right order.""" + await create_mock_platform(hass, test_entities) -@pytest.fixture(autouse=True) -async def calendar_only() -> None: - """Enable only the calendar platform.""" - with patch( - "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", - [Platform.CALENDAR], - ): - yield - - -async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" now = dt_util.utcnow() - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() state = hass.states.get("calendar.calendar_1") assert state diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 02aebf3ce92..120d2e8bfca 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -12,7 +12,6 @@ from collections.abc import AsyncIterator, Callable, Generator from contextlib import asynccontextmanager import datetime import logging -import secrets from typing import Any from unittest.mock import patch import zoneinfo @@ -28,13 +27,14 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .conftest import MockCalendarEntity, create_mock_platform + from tests.common import async_fire_time_changed, async_mock_service _LOGGER = logging.getLogger(__name__) CALENDAR_ENTITY_ID = "calendar.calendar_2" -CONFIG = {calendar.DOMAIN: {"platform": "demo"}} TEST_AUTOMATION_ACTION = { "service": "test.automation", @@ -59,44 +59,6 @@ class FakeSchedule: """Initiailize FakeSchedule.""" self.hass = hass self.freezer = freezer - # Map of event start time to event - self.events: list[calendar.CalendarEvent] = [] - - def create_event( - self, - start: datetime.datetime, - end: datetime.datetime, - summary: str | None = None, - description: str | None = None, - location: str | None = None, - ) -> dict[str, Any]: - """Create a new fake event, used by tests.""" - event = calendar.CalendarEvent( - start=start, - end=end, - summary=summary if summary else f"Event {secrets.token_hex(16)}", - description=description, - location=location, - ) - self.events.append(event) - return event.as_dict() - - async def async_get_events( - self, - hass: HomeAssistant, - start_date: datetime.datetime, - end_date: datetime.datetime, - ) -> list[calendar.CalendarEvent]: - """Get all events in a specific time frame, used by the demo calendar.""" - assert start_date < end_date - values = [] - for event in self.events: - if event.start_datetime_local >= end_date: - continue - if event.end_datetime_local < start_date: - continue - values.append(event) - return values async def fire_time(self, trigger_time: datetime.datetime) -> None: """Fire an alarm and wait.""" @@ -130,19 +92,23 @@ def fake_schedule( # Setup start time for all tests freezer.move_to("2022-04-19 10:31:02+00:00") - schedule = FakeSchedule(hass, freezer) - with patch( - "homeassistant.components.demo.calendar.DemoCalendar.async_get_events", - new=schedule.async_get_events, - ): - yield schedule + return FakeSchedule(hass, freezer) -@pytest.fixture(autouse=True) -async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> None: - """Initialize the demo calendar.""" - assert await async_setup_component(hass, calendar.DOMAIN, CONFIG) - await hass.async_block_till_done() +@pytest.fixture(name="test_entity") +def mock_test_entity(test_entities: list[MockCalendarEntity]) -> MockCalendarEntity: + """Fixture to expose the calendar entity used in tests.""" + return test_entities[1] + + +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( + hass: HomeAssistant, + mock_setup_integration: Any, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture to setup platforms used in the test.""" + await create_mock_platform(hass, test_entities) @asynccontextmanager @@ -207,9 +173,10 @@ async def test_event_start_trigger( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test the a calendar trigger based on start time.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -240,11 +207,12 @@ async def test_event_start_trigger_with_offset( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, offset_str, offset_delta, ) -> None: """Test the a calendar trigger based on start time with an offset.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) @@ -272,9 +240,10 @@ async def test_event_end_trigger( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test the a calendar trigger based on end time.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), ) @@ -309,11 +278,12 @@ async def test_event_end_trigger_with_offset( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, offset_str, offset_delta, ) -> None: """Test the a calendar trigger based on end time with an offset.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) @@ -356,14 +326,15 @@ async def test_multiple_start_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for multiple events.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -389,14 +360,15 @@ async def test_multiple_end_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for multiple events.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -423,14 +395,15 @@ async def test_multiple_events_sharing_start_time( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for every event sharing a start time.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -457,14 +430,15 @@ async def test_overlap_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for events that overlap.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), ) @@ -533,10 +507,11 @@ async def test_update_next_event( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test detection of a new event after initial trigger is setup.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -548,7 +523,7 @@ async def test_update_next_event( assert len(calls()) == 0 # Create a new event between now and when the event fires - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00"), ) @@ -575,10 +550,11 @@ async def test_update_missed( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that new events are missed if they arrive outside the update interval.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -590,7 +566,7 @@ async def test_update_missed( ) assert len(calls()) == 0 - fake_schedule.create_event( + test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), ) @@ -664,13 +640,14 @@ async def test_event_payload( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, set_time_zone: None, create_data, fire_time, payload_data, ) -> None: """Test the fields in the calendar event payload are set.""" - fake_schedule.create_event(**create_data) + test_entity.create_event(**create_data) async with create_automation(hass, EVENT_START): assert len(calls()) == 0 @@ -688,13 +665,14 @@ async def test_trigger_timestamp_window_edge( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test that events in the edge of a scan are included.""" freezer.move_to("2022-04-19 11:00:00+00:00") # Exactly at a TEST_UPDATE_INTERVAL boundary the start time, # making this excluded from the first window. - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:14:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -717,6 +695,7 @@ async def test_event_start_trigger_dst( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" @@ -725,19 +704,19 @@ async def test_event_start_trigger_dst( freezer.move_to("2023-03-12 01:00:00-08:00") # Before DST transition starts - event1_data = fake_schedule.create_event( + event1_data = test_entity.create_event( summary="Event 1", start=datetime.datetime(2023, 3, 12, 1, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 1, 45, tzinfo=tzinfo), ) # During DST transition (Clocks are turned forward at 2am to 3am) - event2_data = fake_schedule.create_event( + event2_data = test_entity.create_event( summary="Event 2", start=datetime.datetime(2023, 3, 12, 2, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 2, 45, tzinfo=tzinfo), ) # After DST transition has ended - event3_data = fake_schedule.create_event( + event3_data = test_entity.create_event( summary="Event 3", start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 0d4ce32fb8b..05ae0ef5f70 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -45,7 +45,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', 'unit_of_measurement': None, @@ -97,7 +97,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', 'unit_of_measurement': None, @@ -125,7 +125,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -163,7 +163,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -225,7 +225,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', 'unit_of_measurement': None, @@ -277,7 +277,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', 'unit_of_measurement': None, @@ -302,7 +302,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_modes': list([ 'off', 'on', @@ -335,7 +335,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_modes': list([ 'off', 'on', diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index f950fce6a68..800a3ce54da 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Cert Expiry config flow.""" +import asyncio import socket import ssl from unittest.mock import patch @@ -48,7 +49,7 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("some error"), ): result = await hass.config_entries.flow.async_configure( @@ -153,7 +154,7 @@ async def test_import_with_name(hass: HomeAssistant) -> None: async def test_bad_import(hass: HomeAssistant) -> None: """Test import step.""" with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ConnectionRefusedError(), ): result = await hass.config_entries.flow.async_init( @@ -198,7 +199,7 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=socket.gaierror(), ): result = await hass.config_entries.flow.async_configure( @@ -208,8 +209,8 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_HOST: "resolve_failed"} with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.timeout(), + "homeassistant.components.cert_expiry.helper.async_get_cert", + side_effect=asyncio.TimeoutError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: HOST} @@ -218,7 +219,7 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_HOST: "connection_timeout"} with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ConnectionRefusedError, ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 00f8a34fb0c..0e0ff1444eb 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -120,7 +120,7 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: async def test_delay_load_during_startup(hass: HomeAssistant) -> None: """Test delayed loading of a config entry during startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) entry.add_to_hass(hass) diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 18a70fa9ab6..1c66a1c91ff 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -57,7 +57,7 @@ async def test_async_setup_entry_bad_cert(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("some error"), ): entry.add_to_hass(hass) @@ -146,7 +146,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=socket.gaierror, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) @@ -174,7 +174,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("something bad"), ): async_fire_time_changed(hass, utcnow() + timedelta(hours=72)) @@ -189,7 +189,8 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() + "homeassistant.components.cert_expiry.helper.async_get_cert", + side_effect=Exception(), ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) await hass.async_block_till_done() diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 89826c98086..0e4e70796f0 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol @@ -25,7 +25,7 @@ from homeassistant.components.climate.const import ( ClimateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -154,7 +154,8 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: result = [] for enum in enum: - result.append((enum, constant_prefix)) + if enum not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF]: + result.append((enum, constant_prefix)) return result @@ -355,11 +356,331 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockClimateEntity() - assert entity.supported_features_compat is ClimateEntityFeature(1) + assert entity.supported_features 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 entity.supported_features is ClimateEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + +async def test_warning_not_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test adding feature flag and warn if missing when methods are set.""" + + called = [] + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + def turn_on(self) -> None: + """Turn on.""" + called.append("turn_on") + + def turn_off(self) -> None: + """Turn off.""" + called.append("turn_off") + + 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( + [MockClimateEntityTest(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), + ) + + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + " Please report it to the author of the 'test' custom integration" + in caplog.text + ) + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + " Please report it to the author of the 'test' custom integration" + in caplog.text + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + { + "entity_id": "climate.test", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + { + "entity_id": "climate.test", + }, + blocking=True, + ) + + assert len(called) == 2 + assert "turn_on" in called + assert "turn_off" in called + + +async def test_implicit_warning_not_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test adding feature flag and warn if missing when methods are not set. + + (implicit by hvac mode) + """ + + class MockClimateEntityTest(MockEntity, ClimateEntity): + """Mock Climate device.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVACMode.*. + """ + return HVACMode.HEAT + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVACMode.OFF, HVACMode.HEAT] + + 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( + [MockClimateEntityTest(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), + ) + + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off" + " methods without setting the proper ClimateEntityFeature. Please report it to the author" + " of the 'test' custom integration" in caplog.text + ) + + +async def test_no_warning_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test no warning when feature flags are set.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + + 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( + [MockClimateEntityTest(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), + ) + + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + not in caplog.text + ) + assert ( + "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + not in caplog.text + ) + assert ( + " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" + not in caplog.text + ) + + +async def test_no_warning_integration_has_migrated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + ) + + 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( + [MockClimateEntityTest(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), + ) + + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + not in caplog.text + ) + assert ( + "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + not in caplog.text + ) + assert ( + " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" + not in caplog.text + ) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 22b84f032f6..e6e793ed106 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -7,6 +7,54 @@ from homeassistant.components import cloud from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component +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", +} + async def mock_cloud(hass, config=None): """Mock cloud.""" diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 1e1877ae13c..798b169393a 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -15,11 +15,22 @@ import jwt import pytest from homeassistant.components.cloud import CloudClient, const, prefs +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(autouse=True) +async def load_homeassistant(hass: HomeAssistant) -> None: + """Load the homeassistant integration. + + This is needed for the cloud integration to work. + """ + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture(name="cloud") async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: """Mock the cloud object. @@ -104,6 +115,13 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: type(mock_cloud).is_connected = is_connected type(mock_cloud.iot).connected = is_connected + def mock_username() -> bool: + """Return the subscription username.""" + return "abcdefghjkl" + + username = PropertyMock(side_effect=mock_username) + type(mock_cloud).username = username + # Properties that we mock as attributes. mock_cloud.expiration_date = utcnow() mock_cloud.subscription_expired = False diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 2be2a8eb2bb..0ebc385b516 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -551,7 +551,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( alexa_settings_version: int, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -649,7 +649,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_v2_no_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config from v2 to v3 when no entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -696,7 +696,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_v2_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config from v2 to v3 when an entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -744,7 +744,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -782,7 +782,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/cloud/test_assist_pipeline.py b/tests/components/cloud/test_assist_pipeline.py new file mode 100644 index 00000000000..7f1411dab45 --- /dev/null +++ b/tests/components/cloud/test_assist_pipeline.py @@ -0,0 +1,16 @@ +"""Test the cloud assist pipeline.""" +import pytest + +from homeassistant.components.cloud.assist_pipeline import ( + async_migrate_cloud_pipeline_engine, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def test_migrate_pipeline_invalid_platform(hass: HomeAssistant) -> None: + """Test migrate pipeline with invalid platform.""" + with pytest.raises(ValueError): + await async_migrate_cloud_pipeline_engine( + hass, Platform.BINARY_SENSOR, "test-engine-id" + ) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 0cd605fd755..0dfa682c07d 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -428,3 +428,47 @@ async def test_async_create_repair_issue_unknown( ) issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) assert issue is None + + +async def test_disconnected(hass: HomeAssistant) -> None: + """Test cleanup when disconnected from the cloud.""" + prefs = MagicMock( + alexa_enabled=False, + google_enabled=True, + async_set_username=AsyncMock(return_value=None), + ) + client = CloudClient(hass, prefs, None, {}, {}) + client.cloud = MagicMock(is_logged_in=True, username="mock-username") + client._google_config = Mock() + client._google_config.async_disable_local_sdk.assert_not_called() + + await client.cloud_disconnected() + client._google_config.async_disable_local_sdk.assert_called_once_with() + + +async def test_logged_out( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test cleanup when logged out from the cloud.""" + + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + + alexa_config_mock = Mock(async_enable_proactive_mode=AsyncMock()) + google_config_mock = Mock(async_sync_entities=AsyncMock()) + cloud.client._alexa_config = alexa_config_mock + cloud.client._google_config = google_config_mock + + await cloud.client.cloud_connected() + await hass.async_block_till_done() + + # Simulate logged out + await cloud.logout() + await hass.async_block_till_done() + + # Alexa is not cleaned up, Google is + assert cloud.client._alexa_config is alexa_config_mock + assert cloud.client._google_config is None + google_config_mock.async_deinitialize.assert_called_once_with() diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 39bf60570f2..7fb2d1aff3e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -236,7 +236,7 @@ async def test_google_entity_registry_sync( assert len(mock_sync.mock_calls) == 3 # When hass is not started yet we wait till started - hass.state = CoreState.starting + hass.set_state(CoreState.starting) hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entry.entity_id}, @@ -338,7 +338,7 @@ async def test_sync_google_on_home_assistant_start( config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() await config.async_connect_agent_user("mock-user-id") @@ -441,8 +441,10 @@ def test_enabled_requires_valid_sub( assert not config.enabled -async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None: - """Test that we set up the integration if used.""" +async def test_setup_google_assistant( + hass: HomeAssistant, mock_conf, cloud_prefs +) -> None: + """Test that we set up the google_assistant integration if enabled in cloud.""" assert await async_setup_component(hass, "homeassistant", {}) mock_conf._cloud.subscription_expired = False @@ -473,8 +475,10 @@ async def test_google_handle_logout( "homeassistant.components.google_assistant.report_state.async_enable_report_state", ) as mock_enable: gconf.async_enable_report_state() + await hass.async_block_till_done() assert len(mock_enable.mock_calls) == 1 + assert len(gconf._on_deinitialize) == 6 # This will trigger a prefs update when we logout. await cloud_prefs.get_cloud_user() @@ -484,8 +488,13 @@ async def test_google_handle_logout( "async_check_token", side_effect=AssertionError("Should not be called"), ): + # Fake logging out; CloudClient.logout_cleanups sets username to None + # and deinitializes the Google config. await cloud_prefs.async_set_username(None) + gconf.async_deinitialize() await hass.async_block_till_done() + # Check listeners are removed: + assert not gconf._on_deinitialize assert len(mock_enable.return_value.mock_calls) == 1 @@ -498,7 +507,7 @@ async def test_google_config_migrate_expose_entity_prefs( google_settings_version: int, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -611,7 +620,7 @@ async def test_google_config_migrate_expose_entity_prefs_v2_no_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config from v2 to v3 when no entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -658,7 +667,7 @@ async def test_google_config_migrate_expose_entity_prefs_v2_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config from v2 to v3 when an entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -705,7 +714,7 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -742,7 +751,7 @@ async def test_google_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 409d86d6e37..4602c054392 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -147,15 +147,19 @@ async def test_google_actions_sync_fails( assert mock_request_sync.call_count == 1 -async def test_login_view_missing_stt_entity( +@pytest.mark.parametrize( + "entity_id", ["stt.home_assistant_cloud", "tts.home_assistant_cloud"] +) +async def test_login_view_missing_entity( hass: HomeAssistant, setup_cloud: None, entity_registry: er.EntityRegistry, hass_client: ClientSessionGenerator, + entity_id: str, ) -> 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") + """Test logging in when a cloud assist pipeline needed entity is missing.""" + # Make sure that the cloud entity does not exist. + entity_registry.async_remove(entity_id) await hass.async_block_till_done() cloud_client = await hass_client() @@ -243,7 +247,7 @@ async def test_login_view_create_pipeline( create_pipeline_mock.assert_awaited_once_with( hass, stt_engine_id="stt.home_assistant_cloud", - tts_engine_id="cloud", + tts_engine_id="tts.home_assistant_cloud", pipeline_name="Home Assistant Cloud", ) @@ -282,7 +286,7 @@ async def test_login_view_create_pipeline_fail( create_pipeline_mock.assert_awaited_once_with( hass, stt_engine_id="stt.home_assistant_cloud", - tts_engine_id="cloud", + tts_engine_id="tts.home_assistant_cloud", pipeline_name="Home Assistant Cloud", ) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c537169bf01..4cef8c8437e 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -103,8 +103,8 @@ async def test_remote_services( assert mock_disconnect.called is False -async def test_startup_shutdown_events(hass: HomeAssistant, mock_cloud_fixture) -> None: - """Test if the cloud will start on startup event.""" +async def test_shutdown_event(hass: HomeAssistant, mock_cloud_fixture) -> None: + """Test if the cloud will stop on shutdown event.""" with patch("hass_nabucasa.Cloud.stop") as mock_stop: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py index 666d8ae7d65..305780e33e1 100644 --- a/tests/components/cloud/test_stt.py +++ b/tests/components/cloud/test_stt.py @@ -14,62 +14,10 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import PIPELINE_DATA + 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]: @@ -143,6 +91,7 @@ async def test_migrating_pipelines( hass_storage: dict[str, Any], ) -> None: """Test migrating pipelines when cloud stt entity is added.""" + entity_id = "stt.home_assistant_cloud" cloud.voice.process_stt = AsyncMock( return_value=STTResponse(True, "Turn the Kitchen Lights on") ) @@ -157,18 +106,18 @@ async def test_migrating_pipelines( 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 cloud.login("test-user", "test-pass") await hass.async_block_till_done() - state = hass.states.get("stt.home_assistant_cloud") + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN - # The stt engine should be updated to the new cloud stt engine id. + # The stt/tts engines should have been updated to the new cloud engine ids. + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] == entity_id assert ( - hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] - == "stt.home_assistant_cloud" + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] + == "tts.home_assistant_cloud" ) # The other items should stay the same. @@ -189,7 +138,6 @@ async def test_migrating_pipelines( 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"] diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 4069edcb744..92a9cb10992 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,23 +1,37 @@ """Tests for cloud tts.""" -from collections.abc import Callable, Coroutine +from collections.abc import AsyncGenerator, Callable, Coroutine +from copy import deepcopy from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError import pytest import voluptuous as vol +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY 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.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.issue_registry import IssueRegistry, IssueSeverity from homeassistant.setup import async_setup_component +from . import PIPELINE_DATA + from tests.typing import ClientSessionGenerator +@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) async def internal_url_mock(hass: HomeAssistant) -> None: """Mock internal URL of the instance.""" @@ -70,6 +84,10 @@ def test_schema() -> None: "gender": "female", }, ), + ( + "tts.home_assistant_cloud", + None, + ), ], ) async def test_prefs_default_voice( @@ -104,9 +122,17 @@ async def test_prefs_default_voice( assert engine.default_options == {"gender": "male", "audio_output": "mp3"} +@pytest.mark.parametrize( + "engine_id", + [ + DOMAIN, + "tts.home_assistant_cloud", + ], +) async def test_provider_properties( hass: HomeAssistant, cloud: MagicMock, + engine_id: str, ) -> None: """Test cloud provider.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -115,7 +141,7 @@ async def test_provider_properties( on_start_callback = cloud.register_on_start.call_args[0][0] await on_start_callback() - engine = get_engine_instance(hass, DOMAIN) + engine = get_engine_instance(hass, engine_id) assert engine is not None assert engine.supported_options == ["gender", "voice", "audio_output"] @@ -132,6 +158,7 @@ async def test_provider_properties( [ ({"platform": DOMAIN}, DOMAIN), ({"engine_id": DOMAIN}, DOMAIN), + ({"engine_id": "tts.home_assistant_cloud"}, "tts.home_assistant_cloud"), ], ) @pytest.mark.parametrize( @@ -241,3 +268,254 @@ async def test_get_tts_audio_logged_out( 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" + + +@pytest.mark.parametrize( + ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + [ + (b"", None), + (None, VoiceError("Boom!")), + ], +) +async def test_tts_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: EntityRegistry, + cloud: MagicMock, + mock_process_tts_return_value: bytes | None, + mock_process_tts_side_effect: Exception | None, +) -> None: + """Test text-to-speech entity.""" + mock_process_tts = AsyncMock( + return_value=mock_process_tts_return_value, + side_effect=mock_process_tts_side_effect, + ) + 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() + entity_id = "tts.home_assistant_cloud" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + url = "/api/tts_get_url" + data = { + "engine_id": entity_id, + "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_{entity_id}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{entity_id}.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" + + state = hass.states.get(entity_id) + assert state + assert state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + # Test removing the entity + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is None + + +async def test_migrating_pipelines( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test migrating pipelines when cloud tts entity is added.""" + entity_id = "tts.home_assistant_cloud" + mock_process_tts = AsyncMock( + return_value=b"", + ) + cloud.voice.process_tts = mock_process_tts + 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() + + await cloud.login("test-user", "test-pass") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + # The stt/tts engines should have been updated to the new cloud engine ids. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] + == "stt.home_assistant_cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == entity_id + + # 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_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] + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ({"engine_id": "tts.home_assistant_cloud"}, "tts.home_assistant_cloud"), + ], +) +async def test_deprecated_voice( + hass: HomeAssistant, + issue_registry: IssueRegistry, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Test we create an issue when a deprecated voice is used for text-to-speech.""" + language = "zh-CN" + deprecated_voice = "XiaoxuanNeural" + replacement_voice = "XiaozhenNeural" + mock_process_tts = AsyncMock( + return_value=b"", + ) + cloud.voice.process_tts = mock_process_tts + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + client = await hass_client() + + # Test with non deprecated voice. + url = "/api/tts_get_url" + data |= { + "message": "There is someone at the door.", + "language": language, + "options": {"voice": replacement_voice}, + } + + 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"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_1c4ec2f170_{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"] == language + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( + "cloud", f"deprecated_voice_{replacement_voice}" + ) + assert issue is None + mock_process_tts.reset_mock() + + # Test with deprecated voice. + data["options"] = {"voice": deprecated_voice} + + 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"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_a1c3b0ac0e_{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"] == language + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( + "cloud", f"deprecated_voice_{deprecated_voice}" + ) + assert issue is not None + assert issue.breaks_in_ha_version == "2024.8.0" + assert issue.is_fixable is True + assert issue.is_persistent is True + assert issue.severity == IssueSeverity.WARNING + assert issue.translation_key == "deprecated_voice" + assert issue.translation_placeholders == { + "deprecated_voice": deprecated_voice, + "replacement_voice": replacement_voice, + } diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index 8ba8b23b65f..6b9e77dcb2a 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -57,7 +57,7 @@ async def init_integration( *, data: dict = ENTRY_CONFIG, options: dict = ENTRY_OPTIONS, - unique_id: str = MOCK_ZONE, + unique_id: str = MOCK_ZONE["name"], skip_setup: bool = False, ) -> MockConfigEntry: """Set up the Cloudflare integration in Home Assistant.""" diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr index eb4364ed0d6..d671640e316 100644 --- a/tests/components/co2signal/snapshots/test_sensor.ambr +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -22,7 +22,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:molecule-co2', + 'original_icon': None, 'original_name': 'CO2 intensity', 'platform': 'co2signal', 'previous_unique_id': None, @@ -38,7 +38,6 @@ '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', }), @@ -72,7 +71,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:molecule-co2', + 'original_icon': None, 'original_name': 'Grid fossil fuel percentage', 'platform': 'co2signal', 'previous_unique_id': None, @@ -88,7 +87,6 @@ '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': '%', }), diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 5b1ade1ee49..29ce783f33a 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,10 +1,10 @@ """Test the CO2 Signal config flow.""" from unittest.mock import AsyncMock, patch -from aioelectricitymaps.exceptions import ( - ElectricityMapsDecodeError, +from aioelectricitymaps import ( + ElectricityMapsConnectionError, ElectricityMapsError, - InvalidToken, + ElectricityMapsInvalidTokenError, ) import pytest @@ -134,11 +134,11 @@ async def test_form_country(hass: HomeAssistant) -> None: ("side_effect", "err_code"), [ ( - InvalidToken, + ElectricityMapsInvalidTokenError, "invalid_auth", ), (ElectricityMapsError("Something else"), "unknown"), - (ElectricityMapsDecodeError("Boom"), "unknown"), + (ElectricityMapsConnectionError("Boom"), "unknown"), ], ids=[ "invalid auth", diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index b79c8e04c23..4d663e1026b 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -2,10 +2,11 @@ from datetime import timedelta from unittest.mock import AsyncMock -from aioelectricitymaps.exceptions import ( - ElectricityMapsDecodeError, +from aioelectricitymaps import ( + ElectricityMapsConnectionError, + ElectricityMapsConnectionTimeoutError, ElectricityMapsError, - InvalidToken, + ElectricityMapsInvalidTokenError, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -42,7 +43,8 @@ async def test_sensor( @pytest.mark.parametrize( "error", [ - ElectricityMapsDecodeError, + ElectricityMapsConnectionTimeoutError, + ElectricityMapsConnectionError, ElectricityMapsError, Exception, ], @@ -93,8 +95,12 @@ async def test_sensor_reauth_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 + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = ( + ElectricityMapsInvalidTokenError + ) + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = ( + ElectricityMapsInvalidTokenError + ) freezer.tick(timedelta(minutes=20)) async_fire_time_changed(hass) diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index f17c46c6f5b..0a0dc04eae0 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -65,8 +65,8 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" with patch( "aiocomelit.api.ComeliteSerialBridgeApi.login", @@ -82,6 +82,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] is not None assert result["errors"]["base"] == error @@ -158,4 +159,5 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["errors"] is not None assert result["errors"]["base"] == error diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index eaa7061551a..7975660fda3 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -6,6 +6,7 @@ from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -15,7 +16,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -289,3 +290,53 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0", + "value_template": "{{ value | multiply(0.1) }}", + "availability": '{{ states("sensor.input1")=="on" }}', + } + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_ON + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"0", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index e6e428388f4..901fc39eb34 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -7,6 +7,7 @@ import os import tempfile from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -22,6 +23,8 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_STOP_COVER, + STATE_OPEN, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -340,3 +343,50 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo 10", + "name": "Test", + "availability": '{{ states("sensor.input1")=="on" }}', + }, + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_OPEN + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"50\n", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 9f28b8cc6d0..64227116cfe 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -7,6 +7,7 @@ import subprocess from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -16,7 +17,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -708,3 +709,52 @@ async def test_template_not_error_when_data_is_none( "Template variable error: 'None' has no attribute 'split' when rendering" not in caplog.text ) + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo January 17, 2022", + "device_class": "date", + "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", + "availability": '{{ states("sensor.input1")=="on" }}', + } + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "2022-01-17" + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"January 17, 2022", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index f1f4096fa91..47d9184f4f9 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -9,6 +9,7 @@ import subprocess import tempfile from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -25,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -710,3 +712,52 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "availability": '{{ states("sensor.input1")=="on" }}', + }, + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_ON + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"50\n", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index c012104a2db..1d1e14173f7 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -22,7 +22,10 @@ async def test_list_areas( """Test list entries.""" area1 = area_registry.async_create("mock 1") area2 = area_registry.async_create( - "mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png" + "mock 2", + aliases={"alias_1", "alias_2"}, + icon="mdi:garage", + picture="/image/example.png", ) await client.send_json({"id": 1, "type": "config/area_registry/list"}) @@ -32,12 +35,14 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "icon": None, "name": "mock 1", "picture": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", }, @@ -58,6 +63,7 @@ async def test_create_area( assert msg["result"] == { "aliases": [], "area_id": ANY, + "icon": None, "name": "mock", "picture": None, } @@ -68,6 +74,7 @@ async def test_create_area( { "id": 2, "aliases": ["alias_1", "alias_2"], + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", "type": "config/area_registry/create", @@ -79,6 +86,7 @@ async def test_create_area( assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": ANY, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", } @@ -148,6 +156,7 @@ async def test_update_area( "id": 1, "aliases": ["alias_1", "alias_2"], "area_id": area.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", "type": "config/area_registry/update", @@ -159,6 +168,7 @@ async def test_update_area( assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", } @@ -169,6 +179,7 @@ async def test_update_area( "id": 2, "aliases": ["alias_1", "alias_1"], "area_id": area.id, + "icon": None, "picture": None, "type": "config/area_registry/update", } @@ -179,6 +190,7 @@ async def test_update_area( assert msg["result"] == { "aliases": ["alias_1"], "area_id": area.id, + "icon": None, "name": "mock 2", "picture": None, } diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 16d89ec08f5..c85b9ba3b0f 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -136,7 +136,7 @@ async def test_delete_unable_self_account( ) -> None: """Test we cannot delete our own account.""" client = await hass_ws_client(hass, hass_access_token) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) await client.send_json( {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": refresh_token.user.id} diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 414f4eb39f2..84afee245a6 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow from homeassistant.components.config import config_entries from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.generated import config_flows from homeassistant.helpers import config_entry_flow, config_validation as cv @@ -1019,12 +1020,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No ) assert resp.status == HTTPStatus.BAD_REQUEST data = await resp.json() - assert data == { - "message": ( - "User input malformed: invalid is not a valid option for " - "dictionary value @ data['choices']" - ) - } + assert data == {"errors": {"choices": "invalid is not a valid option"}} async def test_get_single( @@ -2027,3 +2023,89 @@ async def test_subscribe_entries_ws_filtered( "type": "added", } ] + + +async def test_flow_with_multiple_schema_errors(hass: HomeAssistant, client) -> None: + """Test an config flow with multiple schema errors.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_RADIUS): vol.All(int, vol.Range(min=5)), + } + ), + ) + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + "/api/config/config_entries/flow", json={"handler": "test"} + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + + resp = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + json={"latitude": 30000, "longitude": 30000, "radius": 1}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == { + "errors": { + "latitude": "invalid latitude", + "longitude": "invalid longitude", + "radius": "value must be at least 5", + } + } + + +async def test_flow_with_multiple_schema_errors_base( + hass: HomeAssistant, client +) -> None: + """Test an config flow with multiple schema errors where fields are not in the schema.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + } + ), + ) + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + "/api/config/config_entries/flow", json={"handler": "test"} + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + + resp = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + json={"invalid": 30000, "invalid_2": 30000}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == { + "errors": { + "base": [ + "extra keys not allowed @ data['invalid']", + "extra keys not allowed @ data['invalid_2']", + ], + "latitude": "required key not provided", + } + } diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index a002f2c2d50..46af23a6d1f 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -160,6 +160,7 @@ async def test_list_entities_for_display( entity_category=EntityCategory.DIAGNOSTIC, entity_id="test_domain.test", has_entity_name=True, + icon="mdi:icon", original_name="Hello World", platform="test_platform", translation_key="translations_galore", @@ -170,6 +171,7 @@ async def test_list_entities_for_display( device_id="device123", entity_id="test_domain.nameless", has_entity_name=True, + icon=None, original_name=None, platform="test_platform", unique_id="2345", @@ -231,6 +233,7 @@ async def test_list_entities_for_display( "ec": 1, "ei": "test_domain.test", "en": "Hello World", + "ic": "mdi:icon", "pl": "test_platform", "tk": "translations_galore", }, diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 6a95fb8ebda..4dd786edfd1 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -3,7 +3,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -async def test_config_setup(hass: HomeAssistant, event_loop) -> None: +async def test_config_setup(hass: HomeAssistant) -> None: """Test it sets up hassbian.""" await async_setup_component(hass, "config", {}) assert "config" in hass.config.components diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 35d967f37da..034bfafc1f5 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,687 @@ ]), }) # --- -# 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', + }), + }), + }), + }) +# --- +# name: test_http_api_no_match + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# 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', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[None] + 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_http_processing_intent[homeassistant] + 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_http_processing_intent_alias_added_removed + 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_alias_added_removed.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_alias_added_removed.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': 'Sorry, I am not aware of any device called late added alias', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_conversion_not_expose_new + 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': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_conversion_not_expose_new.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_added_removed + 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_http_processing_intent_entity_added_removed.1 + 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.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': 'Sorry, I am not aware of any device called late added light', + }), + }), + }), + }) +# --- +# 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': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# 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': 'Sorry, I am not aware of any device called my cool light', + }), + }), + }), + }) +# --- +# 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': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# 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': 'Sorry, I am not aware of any device called renamed light', + }), + }), + }), + }) +# --- +# 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({ @@ -255,7 +1035,7 @@ }), }) # --- -# name: test_turn_on_intent[turn kitchen on-homeassistant] +# name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ 'conversation_id': None, 'response': dict({ @@ -285,7 +1065,7 @@ }), }) # --- -# name: test_turn_on_intent[turn on kitchen-None] +# name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'conversation_id': None, 'response': dict({ @@ -315,7 +1095,7 @@ }), }) # --- -# name: test_turn_on_intent[turn on kitchen-homeassistant] +# name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ 'conversation_id': None, 'response': dict({ @@ -345,6 +1125,246 @@ }), }) # --- +# 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_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# 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_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload3] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# 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_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- # name: test_ws_get_agent_info dict({ 'attribution': None, @@ -377,40 +1397,50 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'my cool light', + 'value': 'light.kitchen', }), }), 'intent': dict({ 'name': 'HassTurnOn', }), + 'match': True, + 'sentence_template': ' on ( | [in ])', 'slots': dict({ 'name': 'my cool light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'my cool light', + 'value': 'light.kitchen', }), }), 'intent': dict({ 'name': 'HassTurnOff', }), + 'match': True, + 'sentence_template': '[] ( | [in ]) [to] off', 'slots': dict({ 'name': 'my cool light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ @@ -428,15 +1458,20 @@ 'intent': dict({ 'name': 'HassTurnOn', }), + 'match': True, + 'sentence_template': ' on [all] in ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ @@ -459,16 +1494,21 @@ 'intent': dict({ 'name': 'HassGetState', }), + 'match': True, + 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', 'slots': dict({ 'area': 'kitchen', - 'domain': 'light', + 'domain': 'lights', 'state': 'on', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': False, }), }), + 'unmatched_slots': dict({ + }), }), None, ]), @@ -483,3 +1523,116 @@ }), }) # --- +# name: test_ws_hass_agent_debug_custom_sentence + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'beer_style': dict({ + 'name': 'beer_style', + 'text': 'lager', + 'value': 'lager', + }), + }), + 'file': 'en/beer.yaml', + 'intent': dict({ + 'name': 'OrderBeer', + }), + 'match': True, + 'sentence_template': "I'd like to order a {beer_style} [please]", + 'slots': dict({ + 'beer_style': 'lager', + }), + 'source': 'custom', + 'targets': dict({ + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_null_result + dict({ + 'results': list([ + None, + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'brightness': dict({ + 'name': 'brightness', + 'text': '100%', + 'value': 100, + }), + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'light.demo_1234', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': True, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'brightness': '100%', + 'name': 'test light', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.demo_1234': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range.1 + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'light.demo_1234', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': False, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'name': 'test light', + }), + 'source': 'builtin', + 'targets': dict({ + }), + 'unmatched_slots': dict({ + 'brightness': 1001, + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_sentence_trigger + dict({ + 'results': list([ + dict({ + 'match': True, + 'sentence_template': 'hello[ world]', + 'source': 'trigger', + }), + ]), + }) +# --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c68ec301280..0cf343a3e20 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,7 +1,9 @@ """Test for the default agent.""" + from collections import defaultdict from unittest.mock import AsyncMock, patch +from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest from homeassistant.components import conversation @@ -57,7 +59,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: @@ -70,10 +72,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( @@ -83,9 +85,11 @@ async def test_exposed_areas( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that only expose areas with an exposed entity/device.""" - area_kitchen = area_registry.async_get_or_create("kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom") + """Test that all areas are exposed.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") entry = MockConfigEntry() entry.add_to_hass(hass) @@ -121,15 +125,24 @@ async def test_exposed_areas( # All is well for the exposed kitchen light 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 + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name - # Bedroom is not exposed because it has no exposed entities + # Bedroom has no exposed entities result = await conversation.async_converse( 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 an error because the lights in that area are 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 + + # But we can still ask questions about the bedroom, even with no exposed entities + result = await conversation.async_converse( + hass, "how many lights are on in the bedroom?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER async def test_conversation_agent( @@ -141,8 +154,8 @@ async def test_conversation_agent( conversation.HOME_ASSISTANT_AGENT ) with patch( - "homeassistant.components.conversation.default_agent.get_domains_and_languages", - return_value={"homeassistant": ["dwarvish", "elvish", "entish"]}, + "homeassistant.components.conversation.default_agent.get_languages", + return_value=["dwarvish", "elvish", "entish"], ): assert agent.supported_languages == ["dwarvish", "elvish", "entish"] @@ -188,7 +201,8 @@ async def test_unexposed_entities_skipped( entity_registry: er.EntityRegistry, ) -> None: """Test that unexposed entities are skipped in exposed areas.""" - area_kitchen = area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") # Both lights are in the kitchen exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") @@ -217,6 +231,9 @@ async def test_unexposed_entities_skipped( assert len(calls) == 1 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 + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name # Only one light should be returned hass.states.async_set(exposed_light.entity_id, "on") @@ -307,8 +324,10 @@ async def test_device_area_context( 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") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") # Create 2 lights in each area area_lights = defaultdict(list) @@ -356,13 +375,14 @@ async def test_device_area_context( 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 + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name # 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"] + e.entity_id for e in area_lights[area_kitchen.id] } assert {c.data["entity_id"][0] for c in turn_on_calls} == { - e.entity_id for e in area_lights["kitchen"] + e.entity_id for e in area_lights[area_kitchen.id] } turn_on_calls.clear() @@ -379,13 +399,14 @@ async def test_device_area_context( 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 + assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name # 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"] + e.entity_id for e in area_lights[area_bedroom.id] } assert {c.data["entity_id"][0] for c in turn_on_calls} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } turn_on_calls.clear() @@ -402,13 +423,14 @@ async def test_device_area_context( 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 + assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name # 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"] + e.entity_id for e in area_lights[area_bedroom.id] } assert {c.data["entity_id"][0] for c in turn_off_calls} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } turn_off_calls.clear() @@ -417,7 +439,277 @@ async def test_device_area_context( 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_no_device(hass: HomeAssistant, init_components) -> None: + """Test error message when device/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"] + == "Sorry, I am not aware of any device called missing entity" + ) + + +async def test_error_no_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"] + == "Sorry, I am not aware of any area called missing area" + ) + + +async def test_error_no_device_in_area( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when area is missing a device/entity.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + result = await conversation.async_converse( + hass, "turn on missing entity in the kitchen", 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"] + == "Sorry, I am not aware of any device called missing entity in the kitchen area" + ) + + +async def test_error_no_domain( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no devices/entities exist for a domain.""" + + # We don't have a sentence for turning on all fans + fan_domain = MatchEntity(name="domain", value="fan", text="fans") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"domain": fan_domain}, + entities_list=[fan_domain], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "turn on the fans", 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"] + == "Sorry, I am not aware of any fan" + ) + + +async def test_error_no_domain_in_area( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no devices/entities for a domain exist in an area.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + result = await conversation.async_converse( + hass, "turn on the lights in the kitchen", 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"] + == "Sorry, I am not aware of any light in the kitchen area" + ) + + +async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None: + """Test error message when no entities of a device class exist.""" + + # We don't have a sentence for opening all windows + window_class = MatchEntity(name="device_class", value="window", text="windows") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"device_class": window_class}, + entities_list=[window_class], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "open the windows", 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"] + == "Sorry, I am not aware of any window" + ) + + +async def test_error_no_device_class_in_area( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no entities of a device class exist in an area.""" + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + result = await conversation.async_converse( + hass, "open bedroom windows", 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"] + == "Sorry, I am not aware of any window in the bedroom area" + ) + + +async def test_error_no_intent(hass: HomeAssistant, init_components) -> None: + """Test response with an intent 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 ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I couldn't understand that" + ) + + +async def test_no_states_matched_default_error( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test default response when no states match and slots are missing.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + with patch( + "homeassistant.components.conversation.default_agent.intent.async_handle", + side_effect=intent.NoStatesMatchedError(None, None, None, None), + ): + result = await conversation.async_converse( + hass, "turn on lights in the kitchen", 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"] + == "Sorry, I couldn't understand that" + ) + + +async def test_empty_aliases( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that empty aliases are not added to slot lists.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + area_kitchen = area_registry.async_update(area_kitchen.id, aliases={" "}) + + entry = MockConfigEntry() + entry.add_to_hass(hass) + kitchen_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + device_id=kitchen_device.id, + name="kitchen light", + aliases={" "}, + ) + hass.states.async_set( + kitchen_light.entity_id, + "on", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + + with patch( + "homeassistant.components.conversation.DefaultAgent._recognize", + return_value=None, + ) as mock_recognize_all: + await conversation.async_converse( + hass, "turn on lights in the kitchen", None, Context(), None + ) + + assert mock_recognize_all.call_count > 0 + slot_lists = mock_recognize_all.call_args[0][2] + + # Slot lists should only contain non-empty text + assert slot_lists.keys() == {"area", "name"} + areas = slot_lists["area"] + assert len(areas.values) == 1 + assert areas.values[0].value_out == area_kitchen.id + assert areas.values[0].text_in.text == area_kitchen.normalized_name + + names = slot_lists["name"] + assert len(names.values) == 1 + assert names.values[0].value_out == kitchen_light.entity_id + assert names.values[0].text_in.text == kitchen_light.name + + +async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None: + """Test that sentences for all domains are always loaded.""" + + # light domain is not loaded + assert "light" not in hass.config.components + + result = await conversation.async_converse( + hass, "set brightness of test light to 100%", None, Context(), None + ) + + # Invalid target vs. no intent recognized + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any device called test light" + ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 0f47f9ac3d9..58e94d27aac 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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 the 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,16 @@ 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" + assert data["response"]["data"]["code"] == "no_intent_match" 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 +607,16 @@ async def test_http_api_handle_failure( 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 +635,9 @@ async def test_http_api_unexpected_failure( 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 +658,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 +676,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 +723,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 +738,8 @@ 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 + assert msg["result"]["response"]["data"]["code"] == "no_intent_match" @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -1195,7 +775,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 +803,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 +843,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: @@ -1360,32 +915,6 @@ async def test_language_region(hass: HomeAssistant, init_components) -> None: assert call.data == {"entity_id": ["light.kitchen"]} -async def test_reload_on_new_component(hass: HomeAssistant) -> None: - """Test intents being reloaded when a new component is loaded.""" - language = hass.config.language - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) - - # Load intents - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) - await agent.async_prepare() - - lang_intents = agent._lang_intents.get(language) - assert lang_intents is not None - loaded_components = set(lang_intents.loaded_components) - - # Load another component - assert await async_setup_component(hass, "light", {}) - - # Intents should reload - await agent.async_prepare() - lang_intents = agent._lang_intents.get(language) - assert lang_intents is not None - - assert {"light"} == (lang_intents.loaded_components - loaded_components) - - async def test_non_default_response(hass: HomeAssistant, init_components) -> None: """Test intent response that is not the default.""" hass.states.async_set("cover.front_door", "closed") @@ -1653,7 +1182,7 @@ async def test_ws_hass_agent_debug( "turn my cool light off", "turn on all lights in the kitchen", "how many lights are on in the kitchen?", - "this will not match anything", # null in results + "this will not match anything", # None in results ], } ) @@ -1663,6 +1192,188 @@ async def test_ws_hass_agent_debug( assert msg["success"] assert msg["result"] == snapshot + # Last sentence should be a failed match + assert msg["result"]["results"][-1] is None + # Light state should not have been changed assert len(on_calls) == 0 assert len(off_calls) == 0 + + +async def test_ws_hass_agent_debug_null_result( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a null result.""" + client = await hass_ws_client(hass) + + async def async_recognize(self, user_input, *args, **kwargs): + if user_input.text == "bad sentence": + return None + + return await self.async_recognize(user_input, *args, **kwargs) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize", + async_recognize, + ): + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "bad sentence", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + assert msg["result"]["results"] == [None] + + +async def test_ws_hass_agent_debug_out_of_range( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with an out of range entity.""" + test_light = entity_registry.async_get_or_create("light", "demo", "1234") + hass.states.async_set( + test_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "test light"} + ) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 100%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert results[0]["match"] + + # Brightness is out of range + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 1001%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert not results[0]["match"] + + # Name matched, but brightness didn't + assert results[0]["slots"] == {"name": "test light"} + assert results[0]["unmatched_slots"] == {"brightness": 1001} + + +async def test_ws_hass_agent_debug_custom_sentence( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with a custom sentence.""" + # Expecting testing_config/custom_sentences/en/beer.yaml + intent.async_register(hass, OrderBeerIntentHandler()) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "I'd like to order a lager, please.", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "custom" + assert debug_results[0].get("file") == "en/beer.yaml" + + +async def test_ws_hass_agent_debug_sentence_trigger( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a sentence trigger.""" + calls = async_mock_service(hass, "test", "automation") + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["hello", "hello[ world]"], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + client = await hass_ws_client(hass) + + # Use trigger sentence + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": ["hello world"], + } + ) + await hass.async_block_till_done() + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "trigger" + assert debug_results[0].get("sentence_template") == "hello[ world]" + + # Trigger should not have been executed + assert len(calls) == 0 diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 4fe9fed6bb2..26626a04079 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -7,6 +7,7 @@ from homeassistant.helpers import trigger from homeassistant.setup import async_setup_component from tests.common import async_mock_service +from tests.typing import WebSocketGenerator @pytest.fixture @@ -44,14 +45,16 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None }, ) - await hass.services.async_call( + service_response = await hass.services.async_call( "conversation", "process", { "text": "Ha ha ha", }, blocking=True, + return_response=True, ) + assert service_response["response"]["speech"]["plain"]["speech"] == "Done" await hass.async_block_till_done() assert len(calls) == 1 @@ -66,6 +69,94 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None } +async def test_response(hass: HomeAssistant, setup_comp) -> None: + """Test the firing of events.""" + response = "I'm sorry, Dave. I'm afraid I can't do that" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Open the pod bay door Hal"], + }, + "action": { + "set_conversation_response": response, + }, + } + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "Open the pod bay door Hal", + }, + blocking=True, + return_response=True, + ) + assert service_response["response"]["speech"]["plain"]["speech"] == response + + +async def test_subscribe_trigger_does_not_interfere_with_responses( + hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator +) -> None: + """Test that subscribing to a trigger from the websocket API does not interfere with responses.""" + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 5, + "type": "subscribe_trigger", + "trigger": {"platform": "conversation", "command": ["test sentence"]}, + } + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "test sentence", + }, + blocking=True, + return_response=True, + ) + + # Default response, since no automations with responses are registered + assert service_response["response"]["speech"]["plain"]["speech"] == "Done" + + # Now register a trigger with a response + assert await async_setup_component( + hass, + "automation", + { + "automation test1": { + "trigger": { + "platform": "conversation", + "command": ["test sentence"], + }, + "action": { + "set_conversation_response": "test response", + }, + } + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "test sentence", + }, + blocking=True, + return_response=True, + ) + + # Response will now come through + assert service_response["response"]["speech"]["plain"]["speech"] == "test response" + + async def test_same_trigger_multiple_sentences( hass: HomeAssistant, calls, setup_comp ) -> None: diff --git a/tests/components/coolmaster/test_climate.py b/tests/components/coolmaster/test_climate.py index 5f98082e822..0e306faa8ab 100644 --- a/tests/components/coolmaster/test_climate.py +++ b/tests/components/coolmaster/test_climate.py @@ -60,12 +60,17 @@ async def test_climate_supported_features( ) -> None: """Test the Coolmaster climate supported features.""" assert hass.states.get("climate.l1_100").attributes[ATTR_SUPPORTED_FEATURES] == ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert hass.states.get("climate.l1_101").attributes[ATTR_SUPPORTED_FEATURES] == ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 53bec13d567..a52c083d10f 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -282,7 +282,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("counter.test1", "11"), State("counter.test2", "-22")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -315,7 +315,7 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, DOMAIN, {DOMAIN: {"test1": {}, "test2": {CONF_INITIAL: 10}, "test3": {}}} @@ -332,7 +332,7 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_STEP: 5}}}) diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index d38c65526c2..d1d5b983956 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1179,9 +1179,19 @@ async def test_non_color_light_reports_color( await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 3 + assert hass.states.get("light.group").attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ] + assert ( + hass.states.get("light.group").attributes[ATTR_COLOR_MODE] + == ColorMode.COLOR_TEMP + ) assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] == 250 - # Updating a scene will return a faulty color value for a non-color light causing an exception in hs_color + # Updating a scene will return a faulty color value + # for a non-color light causing an exception in hs_color event_changed_light = { "e": "changed", "id": "1", @@ -1200,7 +1210,9 @@ async def test_non_color_light_reports_color( await mock_deconz_websocket(data=event_changed_light) await hass.async_block_till_done() - # Bug is fixed if we reach this point, but device won't have neither color temp nor color + assert hass.states.get("light.group").attributes[ATTR_COLOR_MODE] == ColorMode.XY + # Bug is fixed if we reach this point + # device won't have neither color temp nor color with pytest.raises(AssertionError): assert hass.states.get("light.group").attributes.get(ATTR_COLOR_TEMP) is None assert hass.states.get("light.group").attributes.get(ATTR_HS_COLOR) is None diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index f3907aac548..20029fe3cdc 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from homeassistant import bootstrap from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -34,4 +35,9 @@ async def test_setup( ) -> None: """Test setup.""" recorder_helper.async_initialize_recorder(hass) + # default_config needs the homeassistant integration, assert it will be + # automatically setup by bootstrap and set it up manually for this test + assert "homeassistant" in bootstrap.CORE_INTEGRATIONS + assert await async_setup_component(hass, "homeassistant", {"foo": "bar"}) + assert await async_setup_component(hass, "default_config", {"foo": "bar"}) diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 97b436ea2b0..18992c0d0f4 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -76,10 +76,10 @@ def test_setup_params(hass: HomeAssistant) -> None: assert state.state == HVACMode.COOL assert state.attributes.get(ATTR_TEMPERATURE) == 21 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22 - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" assert state.attributes.get(ATTR_HUMIDITY) == 67 assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 54 - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" assert state.attributes.get(ATTR_AUX_HEAT) == STATE_OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -256,7 +256,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: async def test_set_fan_mode_bad_attr(hass: HomeAssistant) -> None: """Test setting fan mode without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -267,13 +267,13 @@ async def test_set_fan_mode_bad_attr(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" async def test_set_fan_mode(hass: HomeAssistant) -> None: """Test setting of new fan mode.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" await hass.services.async_call( DOMAIN, @@ -289,7 +289,7 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: """Test setting swing mode without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -300,13 +300,13 @@ async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" async def test_set_swing(hass: HomeAssistant) -> None: """Test setting of new swing mode.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" await hass.services.async_call( DOMAIN, diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index cc0fcfeb2d2..987bc6b9384 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -4,14 +4,12 @@ from unittest.mock import patch import pytest -from homeassistant.components import vacuum from homeassistant.components.demo.vacuum import ( DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL, DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, - DEMO_VACUUM_STATE, FAN_SPEEDS, ) from homeassistant.components.vacuum import ( @@ -20,7 +18,6 @@ from homeassistant.components.vacuum import ( ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, ATTR_PARAMS, - ATTR_STATUS, DOMAIN, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, @@ -34,8 +31,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, - STATE_OFF, - STATE_ON, Platform, ) from homeassistant.core import HomeAssistant @@ -51,7 +46,6 @@ ENTITY_VACUUM_COMPLETE = f"{DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() ENTITY_VACUUM_MINIMAL = f"{DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() ENTITY_VACUUM_MOST = f"{DOMAIN}.{DEMO_VACUUM_MOST}".lower() ENTITY_VACUUM_NONE = f"{DOMAIN}.{DEMO_VACUUM_NONE}".lower() -ENTITY_VACUUM_STATE = f"{DOMAIN}.{DEMO_VACUUM_STATE}".lower() @pytest.fixture @@ -74,166 +68,103 @@ async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): async def test_supported_features(hass: HomeAssistant) -> None: """Test vacuum supported features.""" state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16380 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 219 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.attributes.get(ATTR_FAN_SPEED) is None - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FAN_SPEED) == "medium" + assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 195 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 - assert state.attributes.get(ATTR_STATUS) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_STATUS) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF - - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 13436 assert state.state == STATE_DOCKED - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.attributes.get(ATTR_FAN_SPEED) == "medium" - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS async def test_methods(hass: HomeAssistant) -> None: """Test if methods call the services as expected.""" - hass.states.async_set(ENTITY_VACUUM_BASIC, STATE_ON) + await common.async_start(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_BASIC) + state = hass.states.get(ENTITY_VACUUM_BASIC) + assert state.state == STATE_CLEANING - hass.states.async_set(ENTITY_VACUUM_BASIC, STATE_OFF) + await common.async_stop(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_BASIC) - - await common.async_turn_on(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_turn_off(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_toggle(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_start_pause(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_start_pause(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_stop(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + state = hass.states.get(ENTITY_VACUUM_BASIC) + assert state.state == STATE_IDLE state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100 - assert state.attributes.get(ATTR_STATUS) != "Charging" + await hass.async_block_till_done() + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.state == STATE_DOCKED + await async_setup_component(hass, "notify", {}) + await hass.async_block_till_done() await common.async_locate(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "I'm over here" in state.attributes.get(ATTR_STATUS) + assert state.state == STATE_IDLE await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "Returning home" in state.attributes.get(ATTR_STATUS) + assert state.state == STATE_RETURNING await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_COMPLETE ) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.attributes.get(ATTR_FAN_SPEED) == FAN_SPEEDS[-1] - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_COMPLETE) + await common.async_clean_spot(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "spot" in state.attributes.get(ATTR_STATUS) - assert state.state == STATE_ON - - await common.async_start(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_CLEANING - await common.async_pause(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) + await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_PAUSED - await common.async_stop(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state == STATE_IDLE - - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100 - assert state.state != STATE_DOCKED - - await common.async_return_to_base(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) + await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_RETURNING async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() - state = hass.states.get(ENTITY_VACUUM_STATE) + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_DOCKED - await common.async_set_fan_speed( - hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_STATE - ) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_FAN_SPEED) == FAN_SPEEDS[-1] - - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state == STATE_CLEANING - async def test_unsupported_methods(hass: HomeAssistant) -> None: """Test service calls for unsupported vacuums.""" - hass.states.async_set(ENTITY_VACUUM_NONE, STATE_ON) - await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): await common.async_stop(hass, ENTITY_VACUUM_NONE) - hass.states.async_set(ENTITY_VACUUM_NONE, STATE_OFF) - await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_toggle(hass, ENTITY_VACUUM_NONE) - - # Non supported methods: - with pytest.raises(HomeAssistantError): - await common.async_start_pause(hass, ENTITY_VACUUM_NONE) - with pytest.raises(HomeAssistantError): await common.async_locate(hass, ENTITY_VACUUM_NONE) @@ -244,34 +175,14 @@ async def test_unsupported_methods(hass: HomeAssistant) -> None: await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_NONE ) + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_BASIC) - - # VacuumEntity should not support start and pause methods. - hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_ON) - await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - with pytest.raises(AttributeError): - await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) - - hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_OFF) - await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + await common.async_pause(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): - await common.async_start(hass, ENTITY_VACUUM_COMPLETE) - - # StateVacuumEntity does not support on/off - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, entity_id=ENTITY_VACUUM_STATE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, entity_id=ENTITY_VACUUM_STATE) - - with pytest.raises(HomeAssistantError): - await common.async_toggle(hass, entity_id=ENTITY_VACUUM_STATE) + await common.async_start(hass, ENTITY_VACUUM_NONE) async def test_services(hass: HomeAssistant) -> None: @@ -295,9 +206,7 @@ async def test_services(hass: HomeAssistant) -> None: # Test set fan speed set_fan_speed_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_FAN_SPEED) - await common.async_set_fan_speed( - hass, FAN_SPEEDS[0], entity_id=ENTITY_VACUUM_COMPLETE - ) + await common.async_set_fan_speed(hass, FAN_SPEEDS[0], ENTITY_VACUUM_COMPLETE) assert len(set_fan_speed_calls) == 1 call = set_fan_speed_calls[-1] @@ -309,22 +218,22 @@ async def test_services(hass: HomeAssistant) -> None: async def test_set_fan_speed(hass: HomeAssistant) -> None: """Test vacuum service to set the fan speed.""" - group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_STATE]) + group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_MOST]) old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) - old_state_state = hass.states.get(ENTITY_VACUUM_STATE) + old_state_most = hass.states.get(ENTITY_VACUUM_MOST) await common.async_set_fan_speed(hass, FAN_SPEEDS[0], entity_id=group_vacuums) new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) - new_state_state = hass.states.get(ENTITY_VACUUM_STATE) + new_state_most = hass.states.get(ENTITY_VACUUM_MOST) assert old_state_complete != new_state_complete assert old_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] assert new_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] - assert old_state_state != new_state_state - assert old_state_state.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] - assert new_state_state.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] + assert old_state_most != new_state_most + assert old_state_most.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] + assert new_state_most.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] async def test_send_command(hass: HomeAssistant) -> None: @@ -339,8 +248,4 @@ async def test_send_command(hass: HomeAssistant) -> None: new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) assert old_state_complete != new_state_complete - assert new_state_complete.state == STATE_ON - assert ( - new_state_complete.attributes[ATTR_STATUS] - == "Executing test_command({'p1': 3})" - ) + assert new_state_complete.state == STATE_IDLE diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index ada1c03a923..3831d247ed4 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -238,7 +238,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( async def test_initialize_start(hass: HomeAssistant) -> None: """Test we initialize when HA starts.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component( hass, device_sun_light_trigger.DOMAIN, diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 49912fd282f..ba258af068e 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -1,273 +1,878 @@ """Test Device Tracker config entry things.""" -from homeassistant.components.device_tracker import DOMAIN, config_entry as ce +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components.device_tracker import ( + ATTR_HOST_NAME, + ATTR_IP, + ATTR_MAC, + ATTR_SOURCE_TYPE, + DOMAIN, + SourceType, +) +from homeassistant.components.device_tracker.config_entry import ( + CONNECTED_DEVICE_REGISTERED, + BaseTrackerEntity, + ScannerEntity, + TrackerEntity, +) +from homeassistant.components.zone import ATTR_RADIUS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import MockConfigEntry, MockEntityPlatform, MockPlatform +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" +TEST_MAC_ADDRESS = "12:34:56:AB:CD:EF" + + +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_setups( + config_entry, [Platform.DEVICE_TRACKER] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms( + config_entry, [Platform.DEVICE_TRACKER] + ) + 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, + ), + ) + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: + """Return the config entry used for the tests.""" + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + return config_entry + + +async def create_mock_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entities: list[Entity], +) -> MockConfigEntry: + """Create a device tracker 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), + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="entity_id") +def entity_id_fixture() -> str: + """Return the entity_id of the entity for the test.""" + return "device_tracker.entity1" + + +class MockTrackerEntity(TrackerEntity): + """Test tracker entity.""" + + def __init__( + self, + battery_level: int | None = None, + location_name: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + ) -> None: + """Initialize entity.""" + self._battery_level = battery_level + self._location_name = location_name + self._latitude = latitude + self._longitude = longitude + + @property + def battery_level(self) -> int | None: + """Return the battery level of the device. + + Percentage from 0-100. + """ + return self._battery_level + + @property + def source_type(self) -> SourceType | str: + """Return the source type, eg gps or router, of the device.""" + return SourceType.GPS + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._longitude + + +@pytest.fixture(name="battery_level") +def battery_level_fixture() -> int | None: + """Return the battery level of the entity for the test.""" + return None + + +@pytest.fixture(name="location_name") +def location_name_fixture() -> str | None: + """Return the location_name of the entity for the test.""" + return None + + +@pytest.fixture(name="latitude") +def latitude_fixture() -> float | None: + """Return the latitude of the entity for the test.""" + return None + + +@pytest.fixture(name="longitude") +def longitude_fixture() -> float | None: + """Return the longitude of the entity for the test.""" + return None + + +@pytest.fixture(name="tracker_entity") +def tracker_entity_fixture( + entity_id: str, + battery_level: int | None, + location_name: str | None, + latitude: float | None, + longitude: float | None, +) -> MockTrackerEntity: + """Create a test tracker entity.""" + entity = MockTrackerEntity( + battery_level=battery_level, + location_name=location_name, + latitude=latitude, + longitude=longitude, + ) + entity.entity_id = entity_id + return entity + + +class MockScannerEntity(ScannerEntity): + """Test scanner entity.""" + + def __init__( + self, + ip_address: str | None = None, + mac_address: str | None = None, + hostname: str | None = None, + connected: bool = False, + unique_id: str | None = None, + ) -> None: + """Initialize entity.""" + self._ip_address = ip_address + self._mac_address = mac_address + self._hostname = hostname + self._connected = connected + self._unique_id = unique_id + + @property + def should_poll(self) -> bool: + """Return False for the test entity.""" + return False + + @property + def source_type(self) -> SourceType | str: + """Return the source type, eg gps or router, of the device.""" + return SourceType.ROUTER + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._ip_address + + @property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self._mac_address + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + return self._hostname + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._connected + + @property + def unique_id(self) -> str | None: + """Return hostname of the device.""" + return self._unique_id or self._mac_address + + @callback + def set_connected(self, connected: bool) -> None: + """Set connected state.""" + self._connected = connected + self.async_write_ha_state() + + +@pytest.fixture(name="ip_address") +def ip_address_fixture() -> str | None: + """Return the ip_address of the entity for the test.""" + return None + + +@pytest.fixture(name="mac_address") +def mac_address_fixture() -> str | None: + """Return the mac_address of the entity for the test.""" + return None + + +@pytest.fixture(name="hostname") +def hostname_fixture() -> str | None: + """Return the hostname of the entity for the test.""" + return None + + +@pytest.fixture(name="unique_id") +def unique_id_fixture() -> str | None: + """Return the unique_id of the entity for the test.""" + return None + + +@pytest.fixture(name="scanner_entity") +def scanner_entity_fixture( + entity_id: str, + ip_address: str | None, + mac_address: str | None, + hostname: str | None, + unique_id: str | None, +) -> MockScannerEntity: + """Create a test scanner entity.""" + entity = MockScannerEntity( + ip_address=ip_address, + mac_address=mac_address, + hostname=hostname, + unique_id=unique_id, + ) + entity.entity_id = entity_id + return entity + + +async def test_load_unload_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, + tracker_entity: MockTrackerEntity, +) -> None: + """Test loading and unloading a config entry with a device tracker entity.""" + config_entry = await create_mock_platform(hass, config_entry, [tracker_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + state = hass.states.get(entity_id) + assert not state + + +@pytest.mark.parametrize( + ( + "battery_level", + "location_name", + "latitude", + "longitude", + "expected_state", + "expected_attributes", + ), + [ + ( + None, + None, + 1.0, + 2.0, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: 1.0, + ATTR_LONGITUDE: 2.0, + }, + ), + ( + None, + None, + 50.0, + 60.0, + STATE_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: 50.0, + ATTR_LONGITUDE: 60.0, + }, + ), + ( + None, + None, + -50.0, + -60.0, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: -50.0, + ATTR_LONGITUDE: -60.0, + }, + ), + ( + None, + "zen_zone", + None, + None, + "zen_zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + }, + ), + ( + None, + None, + None, + None, + STATE_UNKNOWN, + {ATTR_SOURCE_TYPE: SourceType.GPS}, + ), + ( + 100, + None, + None, + None, + STATE_UNKNOWN, + {ATTR_BATTERY_LEVEL: 100, ATTR_SOURCE_TYPE: SourceType.GPS}, + ), + ], +) +async def test_tracker_entity_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, + tracker_entity: MockTrackerEntity, + expected_state: str, + expected_attributes: dict[str, Any], +) -> None: + """Test tracker entity state and state attributes.""" + config_entry = await create_mock_platform(hass, config_entry, [tracker_entity]) + assert config_entry.state == ConfigEntryState.LOADED + hass.states.async_set( + "zone.home", + "0", + {ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 200}, + ) + hass.states.async_set( + "zone.other_zone", + "0", + {ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, ATTR_RADIUS: 300}, + ) + await hass.async_block_till_done() + # Write state again to ensure the zone state is taken into account. + tracker_entity.async_write_ha_state() + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes == expected_attributes + + +@pytest.mark.parametrize( + ("ip_address", "mac_address", "hostname"), + [("0.0.0.0", "ad:de:ef:be:ed:fe", "test.hostname.org")], +) +async def test_scanner_entity_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_id: str, + ip_address: str, + mac_address: str, + hostname: str, + scanner_entity: MockScannerEntity, +) -> None: + """Test ScannerEntity based device tracker.""" + # Make device tied to other integration so device tracker entities get enabled + other_config_entry = MockConfigEntry(domain="not_fake_integration") + other_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + name="Device from other integration", + config_entry_id=other_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + ) + + config_entry = await create_mock_platform(hass, config_entry, [scanner_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes == { + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_IP: ip_address, + ATTR_MAC: mac_address, + ATTR_HOST_NAME: hostname, + } + assert entity_state.state == STATE_NOT_HOME + + scanner_entity.set_connected(True) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == STATE_HOME def test_tracker_entity() -> None: - """Test tracker entity.""" + """Test coverage for base TrackerEntity class.""" + entity = TrackerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + assert entity.latitude is None + assert entity.longitude is None + assert entity.location_name is None + assert entity.state is None + assert entity.battery_level is None + assert entity.should_poll is False + assert entity.force_update is True - class TestEntry(ce.TrackerEntity): + class MockEntity(TrackerEntity): """Mock tracker class.""" - should_poll = False + def __init__(self) -> None: + """Initialize.""" + self.is_polling = False - instance = TestEntry() + @property + def should_poll(self) -> bool: + """Return False for the test entity.""" + return self.is_polling - assert instance.force_update + test_entity = MockEntity() - instance.should_poll = True + assert test_entity.force_update - assert not instance.force_update + test_entity.is_polling = True + + assert not test_entity.force_update + + +def test_scanner_entity() -> None: + """Test coverage for base ScannerEntity entity class.""" + entity = ScannerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + with pytest.raises(NotImplementedError): + assert entity.is_connected is None + with pytest.raises(NotImplementedError): + assert entity.state == STATE_NOT_HOME + assert entity.battery_level is None + assert entity.ip_address is None + assert entity.mac_address is None + assert entity.hostname is None + + class MockEntity(ScannerEntity): + """Mock scanner class.""" + + def __init__(self) -> None: + """Initialize.""" + self.mock_mac_address: str | None = None + + @property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self.mock_mac_address + + test_entity = MockEntity() + + assert test_entity.unique_id is None + + test_entity.mock_mac_address = TEST_MAC_ADDRESS + + assert test_entity.unique_id == TEST_MAC_ADDRESS + + +def test_base_tracker_entity() -> None: + """Test coverage for base BaseTrackerEntity entity class.""" + entity = BaseTrackerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + assert entity.battery_level is None + with pytest.raises(NotImplementedError): + assert entity.state_attributes is None async def test_cleanup_legacy( hass: HomeAssistant, + config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - enable_custom_integrations: None, ) -> None: """Test we clean up devices created by old device tracker.""" - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - - device1 = device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device1")} ) - device2 = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device2")} ) - device3 = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device3")} ) # Device with light + device tracker entity - entity1a = entity_registry.async_get_or_create( + entity_entry_1a = entity_registry.async_get_or_create( DOMAIN, "test", "entity1a-unique", config_entry=config_entry, - device_id=device1.id, + device_id=device_entry_1.id, ) - entity1b = entity_registry.async_get_or_create( + entity_entry_1b = entity_registry.async_get_or_create( "light", "test", "entity1b-unique", config_entry=config_entry, - device_id=device1.id, + device_id=device_entry_1.id, ) # Just device tracker entity - entity2a = entity_registry.async_get_or_create( + entity_entry_2a = entity_registry.async_get_or_create( DOMAIN, "test", "entity2a-unique", config_entry=config_entry, - device_id=device2.id, + device_id=device_entry_2.id, ) # Device with no device tracker entities - entity3a = entity_registry.async_get_or_create( + entity_entry_3a = entity_registry.async_get_or_create( "light", "test", "entity3a-unique", config_entry=config_entry, - device_id=device3.id, + device_id=device_entry_3.id, ) # Device tracker but no device - entity4a = entity_registry.async_get_or_create( + entity_entry_4a = entity_registry.async_get_or_create( DOMAIN, "test", "entity4a-unique", config_entry=config_entry, ) # Completely different entity - entity5a = entity_registry.async_get_or_create( + entity_entry_5a = entity_registry.async_get_or_create( "light", "test", "entity4a-unique", config_entry=config_entry, ) - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) - await hass.async_block_till_done() + await create_mock_platform(hass, config_entry, []) - for entity in (entity1a, entity1b, entity3a, entity4a, entity5a): - assert entity_registry.async_get(entity.entity_id) is not None + for entity_entry in ( + entity_entry_1a, + entity_entry_1b, + entity_entry_3a, + entity_entry_4a, + entity_entry_5a, + ): + assert entity_registry.async_get(entity_entry.entity_id) is not None + entity_entry = entity_registry.async_get(entity_entry_2a.entity_id) + assert entity_entry is not None # We've removed device so device ID cleared - assert entity_registry.async_get(entity2a.entity_id).device_id is None + assert entity_entry.device_id is None # Removed because only had device tracker entity - assert device_registry.async_get(device2.id) is None + assert device_registry.async_get(device_entry_2.id) is None +@pytest.mark.parametrize( + ("mac_address", "unique_id"), [(TEST_MAC_ADDRESS, f"{TEST_MAC_ADDRESS}_yo1")] +) async def test_register_mac( hass: HomeAssistant, + config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + mac_address: str, + unique_id: str, ) -> None: """Test registering a mac.""" - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) + await create_mock_platform(hass, config_entry, [scanner_entity]) - mac1 = "12:34:56:AB:CD:EF" - - entity_entry_1 = entity_registry.async_get_or_create( - "device_tracker", - "test", - mac1 + "yo1", - original_name="name 1", - config_entry=config_entry, - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - - ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, ) - await hass.async_block_till_done() - entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) - - assert entity_entry_1.disabled_by is None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by is None +@pytest.mark.parametrize( + ("connections", "mac_address", "unique_id"), + [ + ( + set(), + TEST_MAC_ADDRESS, + f"{TEST_MAC_ADDRESS}_yo1", + ), + ( + {(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)}, + "aa:bb:cc:dd:ee:ff", + "aa_bb_cc_dd_ee_ff", + ), + ], +) +async def test_register_mac_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + connections: set[tuple[str, str]], + mac_address: str, + unique_id: str, +) -> None: + """Test registering a mac when the mac or entity isn't found.""" + registering_scanner_entity = MockScannerEntity(mac_address="aa:bb:cc:dd:ee:ff") + registering_scanner_entity.entity_id = f"{DOMAIN}.registering_scanner_entity" + + await create_mock_platform( + hass, config_entry, [registering_scanner_entity, scanner_entity] + ) + + test_entity_entry = entity_registry.async_get(entity_id) + assert test_entity_entry is not None + assert test_entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=connections, + identifiers={(TEST_DOMAIN, "device1")}, + ) + await hass.async_block_till_done() + + # The entity entry under test should still be disabled. + test_entity_entry = entity_registry.async_get(entity_id) + assert test_entity_entry is not None + assert test_entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + ("mac_address", "unique_id"), [(TEST_MAC_ADDRESS, f"{TEST_MAC_ADDRESS}_yo1")] +) async def test_register_mac_ignored( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + mac_address: str, + unique_id: str, ) -> None: """Test ignoring registering a mac.""" - config_entry = MockConfigEntry(domain="test", pref_disable_new_entities=True) + config_entry = MockConfigEntry(domain=TEST_DOMAIN, pref_disable_new_entities=True) config_entry.add_to_hass(hass) - mac1 = "12:34:56:AB:CD:EF" + await create_mock_platform(hass, config_entry, [scanner_entity]) - entity_entry_1 = entity_registry.async_get_or_create( - "device_tracker", - "test", - mac1 + "yo1", - original_name="name 1", - config_entry=config_entry, - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - - ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, ) - await hass.async_block_till_done() - entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) - - assert entity_entry_1.disabled_by == er.RegistryEntryDisabler.INTEGRATION + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION async def test_connected_device_registered( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test dispatch on connected device being registered.""" - dispatches = [] + dispatches: list[dict[str, Any]] = [] @callback - def _save_dispatch(msg): + def _save_dispatch(msg: dict[str, Any]) -> None: + """Save dispatched message.""" dispatches.append(msg) - unsub = async_dispatcher_connect( - hass, ce.CONNECTED_DEVICE_REGISTERED, _save_dispatch + unsub = async_dispatcher_connect(hass, CONNECTED_DEVICE_REGISTERED, _save_dispatch) + + connected_scanner_entity = MockScannerEntity( + ip_address="5.4.3.2", + mac_address="aa:bb:cc:dd:ee:ff", + hostname="connected", + connected=True, + ) + disconnected_scanner_entity = MockScannerEntity( + ip_address="5.4.3.2", + mac_address="aa:bb:cc:dd:ee:00", + hostname="disconnected", + connected=False, + ) + connected_scanner_entity_bad_ip = MockScannerEntity( + ip_address="", + mac_address="aa:bb:cc:dd:ee:01", + hostname="connected_bad_ip", + connected=True, ) - class MockScannerEntity(ce.ScannerEntity): - """Mock a scanner entity.""" - - @property - def ip_address(self) -> str: - return "5.4.3.2" - - @property - def unique_id(self) -> str: - return self.mac_address - - class MockDisconnectedScannerEntity(MockScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:00" - - @property - def is_connected(self) -> bool: - return False - - @property - def hostname(self) -> str: - return "disconnected" - - class MockConnectedScannerEntity(MockScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:ff" - - @property - def is_connected(self) -> bool: - return True - - @property - def hostname(self) -> str: - return "connected" - - class MockConnectedScannerEntityBadIPAddress(MockConnectedScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:01" - - @property - def ip_address(self) -> str: - return "" - - @property - def hostname(self) -> str: - return "connected_bad_ip" - - async def async_setup_entry(hass, config_entry, async_add_entities): - """Mock setup entry method.""" - async_add_entities( - [ - MockConnectedScannerEntity(), - MockDisconnectedScannerEntity(), - MockConnectedScannerEntityBadIPAddress(), - ] - ) - return True - - platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") - entity_platform = MockEntityPlatform( - hass, platform_name=config_entry.domain, platform=platform + config_entry = await create_mock_platform( + hass, + config_entry, + [ + connected_scanner_entity, + disconnected_scanner_entity, + connected_scanner_entity_bad_ip, + ], ) - assert await entity_platform.async_setup_entry(config_entry) - await hass.async_block_till_done() - full_name = f"{config_entry.domain}.{entity_platform.domain}" + full_name = f"{config_entry.domain}.{DOMAIN}" assert full_name in hass.config.components - assert len(hass.states.async_entity_ids()) == 0 # should be disabled + assert ( + len(hass.states.async_entity_ids(domain_filter=DOMAIN)) == 0 + ) # should be disabled assert len(entity_registry.entities) == 3 assert ( - entity_registry.entities["test_domain.test_aa_bb_cc_dd_ee_ff"].config_entry_id - == "super-mock-id" + entity_registry.entities[ + "device_tracker.test_aa_bb_cc_dd_ee_ff" + ].config_entry_id + == config_entry.entry_id ) unsub() assert dispatches == [ {"ip": "5.4.3.2", "mac": "aa:bb:cc:dd:ee:ff", "host_name": "connected"} ] + + +async def test_entity_has_device_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a scanner entity with device info.""" + + class DeviceInfoScannerEntity(MockScannerEntity): + """Test scanner entity with device info.""" + + @property + def device_info(self) -> dr.DeviceInfo: + """Return device info.""" + return dr.DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)}, + identifiers={(TEST_DOMAIN, "device1")}, + manufacturer="manufacturer", + model="model", + ) + + scanner_entity = DeviceInfoScannerEntity( + ip_address="5.4.3.2", + mac_address=TEST_MAC_ADDRESS, + ) + + config_entry = await create_mock_platform(hass, config_entry, [scanner_entity]) + + assert ( + len(hass.states.async_entity_ids(domain_filter=DOMAIN)) == 1 + ) # should be enabled + assert len(entity_registry.entities) == 1 + assert ( + entity_registry.entities[ + f"{DOMAIN}.{TEST_DOMAIN}_{TEST_MAC_ADDRESS.replace(':', '_').lower()}" + ].config_entry_id + == config_entry.entry_id + ) diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py deleted file mode 100644 index 45f1b21c89a..00000000000 --- a/tests/components/device_tracker/test_entities.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for device tracker entities.""" -import pytest - -from homeassistant.components.device_tracker.config_entry import ( - BaseTrackerEntity, - ScannerEntity, -) -from homeassistant.components.device_tracker.const import ( - ATTR_HOST_NAME, - ATTR_IP, - ATTR_MAC, - ATTR_SOURCE_TYPE, - DOMAIN, - SourceType, -) -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from tests.common import MockConfigEntry - - -async def test_scanner_entity_device_tracker( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - enable_custom_integrations: None, -) -> None: - """Test ScannerEntity based device tracker.""" - # Make device tied to other integration so device tracker entities get enabled - other_config_entry = MockConfigEntry(domain="not_fake_integration") - other_config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - name="Device from other integration", - config_entry_id=other_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, - ) - - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) - await hass.async_block_till_done() - - entity_id = "device_tracker.test_ad_de_ef_be_ed_fe" - entity_state = hass.states.get(entity_id) - assert entity_state.attributes == { - ATTR_SOURCE_TYPE: SourceType.ROUTER, - ATTR_BATTERY_LEVEL: 100, - ATTR_IP: "0.0.0.0", - ATTR_MAC: "ad:de:ef:be:ed:fe", - ATTR_HOST_NAME: "test.hostname.org", - } - assert entity_state.state == STATE_NOT_HOME - - entity = hass.data[DOMAIN].get_entity(entity_id) - entity.set_connected() - await hass.async_block_till_done() - - entity_state = hass.states.get(entity_id) - assert entity_state.state == STATE_HOME - - -def test_scanner_entity() -> None: - """Test coverage for base ScannerEntity entity class.""" - entity = ScannerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None - with pytest.raises(NotImplementedError): - assert entity.is_connected is None - with pytest.raises(NotImplementedError): - assert entity.state == STATE_NOT_HOME - assert entity.battery_level is None - assert entity.ip_address is None - assert entity.mac_address is None - assert entity.hostname is None - - -def test_base_tracker_entity() -> None: - """Test coverage for base BaseTrackerEntity entity class.""" - entity = BaseTrackerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None - assert entity.battery_level is None - with pytest.raises(NotImplementedError): - assert entity.state_attributes is None diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 5618a6645ca..73e6baa2666 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -43,9 +43,9 @@ CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( ComponentSetup = Callable[[], Awaitable[None]] -def create_entry(hass: HomeAssistant) -> MockConfigEntry: +def create_entry(hass: HomeAssistant, unique_id: str | None = None) -> MockConfigEntry: """Create fixture for adding config entry in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA, unique_id=unique_id) entry.add_to_hass(hass) return entry @@ -59,9 +59,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture def config_entry_with_uid(hass: HomeAssistant) -> MockConfigEntry: """Add config entry with unique ID in Home Assistant.""" - config_entry = create_entry(hass) - config_entry.unique_id = "aa:bb:cc:dd:ee:ff" - return config_entry + return create_entry(hass, unique_id="aa:bb:cc:dd:ee:ff") @pytest.fixture diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 1282cddc5e6..6fd24ad9b13 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta 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 @@ from homeassistant.components.dnsip.const import ( 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/common.py b/tests/components/drop_connect/common.py index ea96af03617..2e4d59fe7b2 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -1,5 +1,20 @@ """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,' @@ -49,3 +64,155 @@ TEST_DATA_RO_FILTER = ( 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/conftest.py b/tests/components/drop_connect/conftest.py deleted file mode 100644 index ce68a6f0c13..00000000000 --- a/tests/components/drop_connect/conftest.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Define fixtures available for all tests.""" -import pytest - -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.core import HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.fixture -def config_entry_hub(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_salt(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_leak(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_softener(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_filter(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_protection_valve(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_pump_controller(hass: HomeAssistant): - """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, - ) - - -@pytest.fixture -def config_entry_ro_filter(hass: HomeAssistant): - """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/snapshots/test_binary_sensor.ambr b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f822cd5e252 --- /dev/null +++ b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr @@ -0,0 +1,358 @@ +# serializer version: 1 +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_leak_detected-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.hub_drop_1_c0ffee_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_255_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Hub DROP-1_C0FFEE Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_notification_unread-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.hub_drop_1_c0ffee_notification_unread', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bell-ring', + 'original_name': 'Notification unread', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pending_notification', + 'unique_id': 'DROP-1_C0FFEE_255_pending_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_notification_unread-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hub DROP-1_C0FFEE Notification unread', + 'icon': 'mdi:bell-ring', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_notification_unread', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[leak][binary_sensor.leak_detector_leak_detected-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.leak_detector_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_20_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[leak][binary_sensor.leak_detector_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Leak Detector Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.leak_detector_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[protection_valve][binary_sensor.protection_valve_leak_detected-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.protection_valve_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_78_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[protection_valve][binary_sensor.protection_valve_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Protection Valve Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.protection_valve_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_leak_detected-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.pump_controller_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_83_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Pump Controller Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.pump_controller_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_pump_status-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.pump_controller_pump_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-pump', + 'original_name': 'Pump status', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'DROP-1_C0FFEE_83_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_pump_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pump Controller Pump status', + 'icon': 'mdi:water-pump', + }), + 'context': , + 'entity_id': 'binary_sensor.pump_controller_pump_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[ro_filter][binary_sensor.ro_filter_leak_detected-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.ro_filter_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_255_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ro_filter][binary_sensor.ro_filter_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'RO Filter Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.ro_filter_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[softener][binary_sensor.softener_reserve_capacity_in_use-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.softener_reserve_capacity_in_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Reserve capacity in use', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_in_use', + 'unique_id': 'DROP-1_C0FFEE_0_reserve_in_use', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[softener][binary_sensor.softener_reserve_capacity_in_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Softener Reserve capacity in use', + 'icon': 'mdi:water', + }), + 'context': , + 'entity_id': 'binary_sensor.softener_reserve_capacity_in_use', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py index ca94faeec5e..895921291ef 100644 --- a/tests/components/drop_connect/test_binary_sensor.py +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -1,9 +1,13 @@ """Test DROP binary sensor entities.""" -from homeassistant.components.drop_connect.const import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er from .common import ( TEST_DATA_HUB, @@ -21,172 +25,112 @@ from .common import ( 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_softener, ) -from tests.common import async_fire_mqtt_message +from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_binary_sensors_hub( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +@pytest.mark.parametrize( + ("config_entry", "topic", "reset", "data"), + [ + (config_entry_hub(), TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET, TEST_DATA_HUB), + ( + config_entry_leak(), + TEST_DATA_LEAK_TOPIC, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK, + ), + ( + config_entry_softener(), + TEST_DATA_SOFTENER_TOPIC, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER, + ), + ( + config_entry_protection_valve(), + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE, + ), + ( + config_entry_pump_controller(), + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER, + ), + ( + config_entry_ro_filter(), + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER, + ), + ], + ids=[ + "hub", + "leak", + "softener", + "protection_valve", + "pump_controller", + "ro_filter", + ], +) +async def test_sensors( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + topic: str, + reset: str, + data: str, ) -> None: - """Test DROP binary sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + """Test DROP sensors.""" + config_entry.add_to_hass(hass) - pending_notifications_sensor_name = ( - "binary_sensor.hub_drop_1_c0ffee_notification_unread" + with patch( + "homeassistant.components.drop_connect.PLATFORMS", [Platform.BINARY_SENSOR] + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) - hass.states.async_set(pending_notifications_sensor_name, STATE_UNKNOWN) - leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" - hass.states.async_set(leak_sensor_name, 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) + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id).state == STATE_OFF + + async_fire_mqtt_message(hass, topic, reset) await hass.async_block_till_done() - pending_notifications = hass.states.get(pending_notifications_sensor_name) - assert pending_notifications.state == STATE_ON - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_OFF - - -async def test_binary_sensors_salt( - hass: HomeAssistant, config_entry_salt, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for salt sensors.""" - config_entry_salt.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - salt_sensor_name = "binary_sensor.salt_sensor_salt_low" - hass.states.async_set(salt_sensor_name, STATE_UNKNOWN) - - async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) - await hass.async_block_till_done() - - salt = hass.states.get(salt_sensor_name) - assert salt.state == STATE_ON - - -async def test_binary_sensors_leak( - hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for leak detectors.""" - config_entry_leak.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - leak_sensor_name = "binary_sensor.leak_detector_leak_detected" - hass.states.async_set(leak_sensor_name, 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() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON - - -async def test_binary_sensors_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" - hass.states.async_set(reserve_in_use_sensor_name, 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() - - reserve_in_use = hass.states.get(reserve_in_use_sensor_name) - assert reserve_in_use.state == STATE_ON - - -async def test_binary_sensors_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - leak_sensor_name = "binary_sensor.protection_valve_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) - - async_fire_mqtt_message( - hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id).state == STATE_OFF + + async_fire_mqtt_message(hass, topic, data) await hass.async_block_till_done() - async_fire_mqtt_message( - hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) - await hass.async_block_till_done() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON - - -async def test_binary_sensors_pump_controller( - hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for pump controllers.""" - config_entry_pump_controller.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - leak_sensor_name = "binary_sensor.pump_controller_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) - pump_sensor_name = "binary_sensor.pump_controller_pump_status" - hass.states.async_set(pump_sensor_name, 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() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON - pump = hass.states.get(pump_sensor_name) - assert pump.state == STATE_ON - - -async def test_binary_sensors_ro_filter( - hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for RO filters.""" - config_entry_ro_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - leak_sensor_name = "binary_sensor.ro_filter_leak_detected" - hass.states.async_set(leak_sensor_name, 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() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON + 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/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_coordinator.py deleted file mode 100644 index 50f2633e241..00000000000 --- a/tests/components/drop_connect/test_coordinator.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Test DROP coordinator.""" -from homeassistant.components.drop_connect.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC - -from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClient - - -async def test_bad_json( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: - """Test bad JSON.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) - - async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") - 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 == STATE_UNKNOWN - - -async def test_unload( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: - """Test entity unload.""" - # Load the hub device - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, 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 round(float(current_flow_sensor.state), 1) == 5.8 - - # Unload the device - await hass.config_entries.async_unload(config_entry_hub.entry_id) - await hass.async_block_till_done() - - assert config_entry_hub.state is ConfigEntryState.NOT_LOADED - - # Verify sensor is unavailable - current_flow_sensor = hass.states.get(current_flow_sensor_name) - assert current_flow_sensor - assert current_flow_sensor.state == STATE_UNAVAILABLE - - -async def test_no_mqtt(hass: HomeAssistant, config_entry_hub) -> None: - """Test no MQTT.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - 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 is None diff --git a/tests/components/drop_connect/test_init.py b/tests/components/drop_connect/test_init.py new file mode 100644 index 00000000000..4963119b349 --- /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 index 24877069367..1e00f6031d4 100644 --- a/tests/components/drop_connect/test_select.py +++ b/tests/components/drop_connect/test_select.py @@ -1,6 +1,5 @@ """Test DROP select entities.""" -from homeassistant.components.drop_connect.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -9,21 +8,23 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC +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, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_selects_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP binary sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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) @@ -36,6 +37,14 @@ async def test_selects_hub( 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() @@ -43,6 +52,7 @@ async def test_selects_hub( 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, @@ -50,7 +60,9 @@ async def test_selects_hub( 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() diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py index 589fd08488c..43da49af884 100644 --- a/tests/components/drop_connect/test_sensor.py +++ b/tests/components/drop_connect/test_sensor.py @@ -1,8 +1,7 @@ """Test DROP sensor entities.""" -from homeassistant.components.drop_connect.const import DOMAIN + from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( TEST_DATA_FILTER, @@ -26,36 +25,41 @@ from .common import ( 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, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + 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" - hass.states.async_set(peak_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(peak_flow_sensor_name).state == STATE_UNKNOWN used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" - hass.states.async_set(used_today_sensor_name, STATE_UNKNOWN) + assert hass.states.get(used_today_sensor_name).state == STATE_UNKNOWN average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" - hass.states.async_set(average_usage_sensor_name, STATE_UNKNOWN) + assert hass.states.get(average_usage_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" - hass.states.async_set(psi_high_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_high_sensor_name).state == STATE_UNKNOWN psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" - hass.states.async_set(psi_low_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_low_sensor_name).state == STATE_UNKNOWN battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + 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() @@ -64,49 +68,47 @@ async def test_sensors_hub( current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 5.8 + assert current_flow_sensor.state == "5.77" peak_flow_sensor = hass.states.get(peak_flow_sensor_name) assert peak_flow_sensor - assert round(float(peak_flow_sensor.state), 1) == 13.8 + assert peak_flow_sensor.state == "13.8" used_today_sensor = hass.states.get(used_today_sensor_name) assert used_today_sensor - assert round(float(used_today_sensor.state), 1) == 881.1 # liters + assert used_today_sensor.state == "881.13030096168" # liters average_usage_sensor = hass.states.get(average_usage_sensor_name) assert average_usage_sensor - assert round(float(average_usage_sensor.state), 1) == 287.7 # liters + assert average_usage_sensor.state == "287.691295584" # liters psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 428.9 # centibars + assert psi_sensor.state == "428.8538854" # centibars psi_high_sensor = hass.states.get(psi_high_sensor_name) assert psi_high_sensor - assert round(float(psi_high_sensor.state), 1) == 427.5 # centibars + assert psi_high_sensor.state == "427.474934" # centibars psi_low_sensor = hass.states.get(psi_low_sensor_name) assert psi_low_sensor - assert round(float(psi_low_sensor.state), 1) == 420.6 # centibars + assert psi_low_sensor.state == "420.580177" # centibars battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 50 + assert battery_sensor.state == "50" -async def test_sensors_leak( - hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_leak(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for leak detectors.""" - config_entry_leak.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.leak_detector_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + 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() @@ -115,29 +117,29 @@ async def test_sensors_leak( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 100 + assert battery_sensor.state == "100" temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 20.1 # C + assert temp_sensor.state == "20.1111111111111" # °C async def test_sensors_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.softener_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.softener_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN capacity_sensor_name = "sensor.softener_capacity_remaining" - hass.states.async_set(capacity_sensor_name, STATE_UNKNOWN) + 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() @@ -146,35 +148,33 @@ async def test_sensors_softener( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 20 + assert battery_sensor.state == "20" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 5.0 + assert current_flow_sensor.state == "5.0" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 348.2 # centibars + assert psi_sensor.state == "348.1852285" # centibars capacity_sensor = hass.states.get(capacity_sensor_name) assert capacity_sensor - assert round(float(capacity_sensor.state), 1) == 3785.4 # liters + assert capacity_sensor.state == "3785.411784" # liters -async def test_sensors_filter( - hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_filter(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for filters.""" - config_entry_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.filter_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.filter_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + 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() @@ -183,33 +183,33 @@ async def test_sensors_filter( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert round(float(battery_sensor.state), 1) == 12.0 + assert battery_sensor.state == "12" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 19.8 + assert current_flow_sensor.state == "19.84" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 263.4 # centibars + assert psi_sensor.state == "263.3797174" # centibars async def test_sensors_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.protection_valve_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.protection_valve_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + 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 @@ -222,35 +222,35 @@ async def test_sensors_protection_valve( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 0 + assert battery_sensor.state == "0" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 7.1 + assert current_flow_sensor.state == "7.1" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 422.6 # centibars + assert psi_sensor.state == "422.6486041" # centibars temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 21.4 # C + assert temp_sensor.state == "21.3888888888889" # °C async def test_sensors_pump_controller( - hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for pump controllers.""" - config_entry_pump_controller.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.pump_controller_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.pump_controller_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + 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 @@ -263,35 +263,35 @@ async def test_sensors_pump_controller( current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 2.2 + assert current_flow_sensor.state == "2.2" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 428.9 # centibars + assert psi_sensor.state == "428.8538854" # centibars temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 20.4 # C + assert temp_sensor.state == "20.4444444444444" # °C async def test_sensors_ro_filter( - hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for RO filters.""" - config_entry_ro_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(tds_in_sensor_name, STATE_UNKNOWN) + assert hass.states.get(tds_in_sensor_name).state == STATE_UNKNOWN tds_out_sensor_name = "sensor.ro_filter_outlet_tds" - hass.states.async_set(tds_out_sensor_name, STATE_UNKNOWN) + assert hass.states.get(tds_out_sensor_name).state == STATE_UNKNOWN cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" - hass.states.async_set(cart1_sensor_name, STATE_UNKNOWN) + assert hass.states.get(cart1_sensor_name).state == STATE_UNKNOWN cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" - hass.states.async_set(cart2_sensor_name, STATE_UNKNOWN) + assert hass.states.get(cart2_sensor_name).state == STATE_UNKNOWN cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" - hass.states.async_set(cart3_sensor_name, STATE_UNKNOWN) + 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() @@ -300,20 +300,20 @@ async def test_sensors_ro_filter( tds_in_sensor = hass.states.get(tds_in_sensor_name) assert tds_in_sensor - assert int(tds_in_sensor.state) == 164 + assert tds_in_sensor.state == "164" tds_out_sensor = hass.states.get(tds_out_sensor_name) assert tds_out_sensor - assert int(tds_out_sensor.state) == 9 + assert tds_out_sensor.state == "9" cart1_sensor = hass.states.get(cart1_sensor_name) assert cart1_sensor - assert int(cart1_sensor.state) == 59 + assert cart1_sensor.state == "59" cart2_sensor = hass.states.get(cart2_sensor_name) assert cart2_sensor - assert int(cart2_sensor.state) == 80 + assert cart2_sensor.state == "80" cart3_sensor = hass.states.get(cart3_sensor_name) assert cart3_sensor - assert int(cart3_sensor.state) == 59 + assert cart3_sensor.state == "59" diff --git a/tests/components/drop_connect/test_switch.py b/tests/components/drop_connect/test_switch.py index d7d954915c6..0e244e9ab59 100644 --- a/tests/components/drop_connect/test_switch.py +++ b/tests/components/drop_connect/test_switch.py @@ -1,6 +1,5 @@ """Test DROP switch entities.""" -from homeassistant.components.drop_connect.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -8,7 +7,6 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( TEST_DATA_FILTER, @@ -23,253 +21,251 @@ from .common import ( 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, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_switches_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP switches for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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" - hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + assert hass.states.get(water_supply_switch_name).state == STATE_UNKNOWN bypass_switch_name = "switch.hub_drop_1_c0ffee_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + 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() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON + 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() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_OFF + 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() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF async def test_switches_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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() - - water_supply_switch_name = "switch.protection_valve_water_supply" - hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + 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() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_OFF + 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() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON + assert hass.states.get(water_supply_switch_name).state == STATE_ON async def test_switches_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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() - - bypass_switch_name = "switch.softener_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF async def test_switches_filter( - hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for filters.""" - config_entry_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + 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() - - bypass_switch_name = "switch.filter_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + 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() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF diff --git a/tests/components/eafm/test_config_flow.py b/tests/components/eafm/test_config_flow.py index 5addc80e5ae..208f406d8b9 100644 --- a/tests/components/eafm/test_config_flow.py +++ b/tests/components/eafm/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch import pytest -from voluptuous.error import MultipleInvalid +from voluptuous.error import Invalid from homeassistant import config_entries from homeassistant.components.eafm import const @@ -32,7 +32,7 @@ async def test_flow_invalid_station(hass: HomeAssistant, mock_get_stations) -> N ) assert result["type"] == "form" - with pytest.raises(MultipleInvalid): + with pytest.raises(Invalid): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"station": "My other station"} ) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 8572764ce4d..642e4830016 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -98,6 +98,8 @@ async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -115,6 +117,8 @@ async def test_aux_heat_supported_with_heat_pump(hass: HomeAssistant) -> None: | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/ecovacs/__init__.py b/tests/components/ecovacs/__init__.py new file mode 100644 index 00000000000..7305ba8c785 --- /dev/null +++ b/tests/components/ecovacs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecovacs integration.""" diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py new file mode 100644 index 00000000000..d0f0668cc8c --- /dev/null +++ b/tests/components/ecovacs/conftest.py @@ -0,0 +1,152 @@ +"""Common fixtures for the Ecovacs tests.""" +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from deebot_client.const import PATH_API_APPSVR_APP +from deebot_client.device import Device +from deebot_client.exceptions import ApiError +from deebot_client.models import Credentials +import pytest + +from homeassistant.components.ecovacs import PLATFORMS +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import VALID_ENTRY_DATA_CLOUD + +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.ecovacs.async_setup_entry", return_value=True + ) as async_setup_entry: + yield async_setup_entry + + +@pytest.fixture +def mock_config_entry_data() -> dict[str, Any]: + """Return the default mocked config entry data.""" + return VALID_ENTRY_DATA_CLOUD + + +@pytest.fixture +def mock_config_entry(mock_config_entry_data: dict[str, Any]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=mock_config_entry_data[CONF_USERNAME], + domain=DOMAIN, + data=mock_config_entry_data, + ) + + +@pytest.fixture +def device_fixture() -> str: + """Device class, which should be returned by the get_devices api call.""" + return "yna5x1" + + +@pytest.fixture +def mock_authenticator(device_fixture: str) -> Generator[Mock, None, None]: + """Mock the authenticator.""" + with patch( + "homeassistant.components.ecovacs.controller.Authenticator", + autospec=True, + ) as mock, patch( + "homeassistant.components.ecovacs.config_flow.Authenticator", + new=mock, + ): + authenticator = mock.return_value + authenticator.authenticate.return_value = Credentials("token", "user_id", 0) + + devices = [ + load_json_object_fixture(f"devices/{device_fixture}/device.json", DOMAIN) + ] + + async def post_authenticated( + path: str, + json: dict[str, Any], + *, + query_params: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if path == PATH_API_APPSVR_APP: + return {"code": 0, "devices": devices, "errno": "0"} + raise ApiError("Path not mocked: {path}") + + authenticator.post_authenticated.side_effect = post_authenticated + yield authenticator + + +@pytest.fixture +def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: + """Mock authenticator.authenticate.""" + return mock_authenticator.authenticate + + +@pytest.fixture +def mock_mqtt_client(mock_authenticator: Mock) -> Mock: + """Mock the MQTT client.""" + with patch( + "homeassistant.components.ecovacs.controller.MqttClient", + autospec=True, + ) as mock, patch( + "homeassistant.components.ecovacs.config_flow.MqttClient", + new=mock, + ): + client = mock.return_value + client._authenticator = mock_authenticator + client.subscribe.return_value = lambda: None + yield client + + +@pytest.fixture +def mock_device_execute() -> AsyncMock: + """Mock the device execute function.""" + with patch.object( + Device, "_execute_command", return_value=True + ) as mock_device_execute: + yield mock_device_execute + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return PLATFORMS + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, + mock_mqtt_client: Mock, + mock_device_execute: AsyncMock, + platforms: Platform | list[Platform], +) -> MockConfigEntry: + """Set up the Ecovacs integration for testing.""" + if not isinstance(platforms, list): + platforms = [platforms] + + with patch( + "homeassistant.components.ecovacs.PLATFORMS", + platforms, + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry + + +@pytest.fixture +def controller( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> EcovacsController: + """Get the controller for the config entry.""" + return hass.data[DOMAIN][init_integration.entry_id] diff --git a/tests/components/ecovacs/const.py b/tests/components/ecovacs/const.py new file mode 100644 index 00000000000..237c7fa5c85 --- /dev/null +++ b/tests/components/ecovacs/const.py @@ -0,0 +1,28 @@ +"""Test ecovacs constants.""" + + +from homeassistant.components.ecovacs.const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, +) +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME + +VALID_ENTRY_DATA_CLOUD = { + CONF_USERNAME: "username@cloud", + CONF_PASSWORD: "password", + CONF_COUNTRY: "IT", +} + +VALID_ENTRY_DATA_SELF_HOSTED = VALID_ENTRY_DATA_CLOUD | { + CONF_USERNAME: "username@self-hosted", + CONF_OVERRIDE_REST_URL: "http://localhost:8000", + CONF_OVERRIDE_MQTT_URL: "mqtt://localhost:1883", +} + +VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT = VALID_ENTRY_DATA_SELF_HOSTED | { + CONF_VERIFY_MQTT_CERTIFICATE: True, +} + +IMPORT_DATA = VALID_ENTRY_DATA_CLOUD | {CONF_CONTINENT: "EU"} diff --git a/tests/components/ecovacs/fixtures/devices/yna5x1/device.json b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json new file mode 100644 index 00000000000..0b2957af93b --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json @@ -0,0 +1,22 @@ +{ + "did": "E1234567890000000001", + "name": "E1234567890000000001", + "class": "yna5xi", + "resource": "upQ6", + "company": "eco-ng", + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-as.ww.ecouser.net" + }, + "deviceName": "DEEBOT OZMO 950 Series", + "icon": "https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1", + "UILogicId": "DX_9G", + "materialNo": "110-1820-0101", + "pid": "5c19a91ca1e6ee000178224a", + "product_category": "DEEBOT", + "model": "DX9G", + "nick": "Ozmo 950", + "homeSort": 9999, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..b42aeda6fcc --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-entity_entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ozmo_950_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_mop_attached', + 'unique_id': 'E1234567890000000001_water_mop_attached', + 'unit_of_measurement': None, + }) +# --- +# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-state] + 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.ozmo_950_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_mop_attached', + 'unique_id': 'E1234567890000000001_water_mop_attached', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr new file mode 100644 index 00000000000..45b7ef1cc51 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_buttons[yna5x1][button.ozmo_950_relocate:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': 'E1234567890000000001_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Relocate', + }), + 'context': , + 'entity_id': 'button.ozmo_950_relocate', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': 'E1234567890000000001_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_filter_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset main brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_main_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brushes_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_side_brushes_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brushes lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brushes_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset side brushes lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_side_brushes_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a4291f9fe25 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_diagnostics[username@cloud] + dict({ + 'config': dict({ + 'data': dict({ + 'country': 'IT', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ecovacs', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + 'devices': list([ + dict({ + 'UILogicId': 'DX_9G', + 'class': 'yna5xi', + 'company': 'eco-ng', + 'deviceName': 'DEEBOT OZMO 950 Series', + 'did': '**REDACTED**', + 'homeSort': 9999, + 'icon': 'https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1', + 'materialNo': '110-1820-0101', + 'model': 'DX9G', + 'name': '**REDACTED**', + 'nick': 'Ozmo 950', + 'otaUpgrade': dict({ + }), + 'pid': '5c19a91ca1e6ee000178224a', + 'product_category': 'DEEBOT', + 'resource': 'upQ6', + 'service': dict({ + 'jmq': 'jmq-ngiot-eu.dc.ww.ecouser.net', + 'mqs': 'api-ngiot.dc-as.ww.ecouser.net', + }), + 'status': 1, + }), + ]), + 'legacy_devices': list([ + ]), + }) +# --- +# name: test_diagnostics[username@self-hosted] + dict({ + 'config': dict({ + 'data': dict({ + 'country': 'IT', + 'override_mqtt_url': '**REDACTED**', + 'override_rest_url': '**REDACTED**', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ecovacs', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + 'devices': list([ + dict({ + 'UILogicId': 'DX_9G', + 'class': 'yna5xi', + 'company': 'eco-ng', + 'deviceName': 'DEEBOT OZMO 950 Series', + 'did': '**REDACTED**', + 'homeSort': 9999, + 'icon': 'https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1', + 'materialNo': '110-1820-0101', + 'model': 'DX9G', + 'name': '**REDACTED**', + 'nick': 'Ozmo 950', + 'otaUpgrade': dict({ + }), + 'pid': '5c19a91ca1e6ee000178224a', + 'product_category': 'DEEBOT', + 'resource': 'upQ6', + 'service': dict({ + 'jmq': 'jmq-ngiot-eu.dc.ww.ecouser.net', + 'mqs': 'api-ngiot.dc-as.ww.ecouser.net', + }), + 'status': 1, + }), + ]), + 'legacy_devices': list([ + ]), + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr new file mode 100644 index 00000000000..c5d34090204 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_devices_in_dr[E1234567890000000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ecovacs', + 'E1234567890000000001', + ), + }), + 'is_new': False, + 'manufacturer': 'Ecovacs', + 'model': 'DEEBOT OZMO 950 Series', + 'name': 'Ozmo 950', + 'name_by_user': None, + 'serial_number': 'E1234567890000000001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr new file mode 100644 index 00000000000..bb0d0b35f6a --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_number_entities[yna5x1][number.ozmo_950_volume:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.ozmo_950_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'E1234567890000000001_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[yna5x1][number.ozmo_950_volume:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.ozmo_950_volume', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr new file mode 100644 index 00000000000..4b01d448fd8 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ozmo_950_water_flow_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water flow level', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': 'E1234567890000000001_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Water flow level', + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'context': , + 'entity_id': 'select.ozmo_950_water_flow_level', + 'last_changed': , + 'last_updated': , + 'state': 'ultrahigh', + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f07722afb53 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -0,0 +1,587 @@ +# serializer version: 1 +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_area', + 'unique_id': 'E1234567890000000001_stats_area', + 'unit_of_measurement': 'm²', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Area cleaned', + 'unit_of_measurement': 'm²', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_area_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_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': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000001_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Ozmo 950 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_battery', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_time', + 'unique_id': 'E1234567890000000001_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Ozmo 950 Cleaning duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_cleaning_duration', + 'last_changed': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_error:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': 'E1234567890000000001_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_error:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'description': 'NoError: Robot is operational', + 'friendly_name': 'Ozmo 950 Error', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_error', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_filter', + 'unique_id': 'E1234567890000000001_lifespan_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Filter lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_filter_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '56', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_ip_address:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ip', + 'unique_id': 'E1234567890000000001_network_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_ip_address:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 IP address', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_ip_address', + 'last_changed': , + 'last_updated': , + 'state': '192.168.0.10', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_brush', + 'unique_id': 'E1234567890000000001_lifespan_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Main brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_main_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brushes_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_side_brushes_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brushes lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_side_brush', + 'unique_id': 'E1234567890000000001_lifespan_side_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brushes_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Side brushes lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_side_brushes_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_area', + 'unique_id': 'E1234567890000000001_total_stats_area', + 'unit_of_measurement': 'm²', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Total area cleaned', + 'state_class': , + 'unit_of_measurement': 'm²', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_area_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': 'E1234567890000000001_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Ozmo 950 Total cleaning duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_cleaning_duration', + 'last_changed': , + 'last_updated': , + 'state': '40.000', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleanings:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_cleanings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleanings', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_cleanings', + 'unique_id': 'E1234567890000000001_total_stats_cleanings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleanings:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Total cleanings', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_cleanings', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_rssi', + 'unique_id': 'E1234567890000000001_network_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Wi-Fi RSSI', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_wi_fi_rssi', + 'last_changed': , + 'last_updated': , + 'state': '-62', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_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.ozmo_950_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ssid', + 'unique_id': 'E1234567890000000001_network_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'Testnetwork', + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c645502a831 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:entity-registry] + 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.ozmo_950_advanced_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Advanced mode', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'advanced_mode', + 'unique_id': 'E1234567890000000001_advanced_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Advanced mode', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_advanced_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_boost_suction:entity-registry] + 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.ozmo_950_carpet_auto_boost_suction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carpet auto-boost suction', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_auto_fan_boost', + 'unique_id': 'E1234567890000000001_carpet_auto_fan_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_boost_suction:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Carpet auto-boost suction', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_carpet_auto_boost_suction', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:entity-registry] + 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.ozmo_950_continuous_cleaning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Continuous cleaning', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'continuous_cleaning', + 'unique_id': 'E1234567890000000001_continuous_cleaning', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Continuous cleaning', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_continuous_cleaning', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py new file mode 100644 index 00000000000..f72ad6bd7e5 --- /dev/null +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -0,0 +1,58 @@ +"""Tests for Ecovacs binary sensors.""" + +from deebot_client.events import WaterAmount, WaterInfoEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import STATE_OFF, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import notify_and_wait + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BINARY_SENSOR + + +async def test_mop_attached( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, +) -> None: + """Test mop_attached binary sensor.""" + entity_id = "binary_sensor.ozmo_950_mop_attached" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") + assert entity_entry.device_id + + device = controller.devices[0] + + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + event_bus = device.events + await notify_and_wait( + hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) + ) + + assert (state := hass.states.get(state.entity_id)) + assert entity_entry == snapshot(name=f"{entity_id}-state") + + await notify_and_wait( + hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False) + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_OFF diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py new file mode 100644 index 00000000000..24c926b1f77 --- /dev/null +++ b/tests/components/ecovacs/test_button.py @@ -0,0 +1,113 @@ +"""Tests for Ecovacs sensors.""" + +from deebot_client.command import Command +from deebot_client.commands.json import ResetLifeSpan, SetRelocationState +from deebot_client.events import LifeSpan +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +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.freeze_time("2024-01-01 00:00:00"), +] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BUTTON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ( + "yna5x1", + [ + ("button.ozmo_950_relocate", SetRelocationState()), + ( + "button.ozmo_950_reset_main_brush_lifespan", + ResetLifeSpan(LifeSpan.BRUSH), + ), + ( + "button.ozmo_950_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.ozmo_950_reset_side_brushes_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_buttons( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entities: list[tuple[str, Command]], +) -> None: + """Test that sensor entity snapshots match.""" + assert hass.states.async_entity_ids() == [e[0] for e in entities] + device = controller.devices[0] + for entity_id, command in entities: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device._execute_command.reset_mock() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(command) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == "2024-01-01T00:00:00+00:00" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "button.ozmo_950_reset_main_brush_lifespan", + "button.ozmo_950_reset_filter_lifespan", + "button.ozmo_950_reset_side_brushes_lifespan", + ], + ), + ], +) +async def test_disabled_by_default_buttons( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default buttons.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py new file mode 100644 index 00000000000..5e02ec7dede --- /dev/null +++ b/tests/components/ecovacs/test_config_flow.py @@ -0,0 +1,417 @@ +"""Test Ecovacs config flow.""" +from collections.abc import Awaitable, Callable +import ssl +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aiohttp import ClientError +from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.mqtt_client import create_mqtt_config +import pytest + +from homeassistant.components.ecovacs.const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, + DOMAIN, + InstanceMode, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_USERNAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from .const import ( + IMPORT_DATA, + VALID_ENTRY_DATA_CLOUD, + VALID_ENTRY_DATA_SELF_HOSTED, + VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, +) + +from tests.common import MockConfigEntry + +_USER_STEP_SELF_HOSTED = {CONF_MODE: InstanceMode.SELF_HOSTED} + +_TEST_FN_AUTH_ARG = "user_input_auth" +_TEST_FN_USER_ARG = "user_input_user" + + +async def _test_user_flow( + hass: HomeAssistant, + user_input_auth: dict[str, Any], +) -> dict[str, Any]: + """Test config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert not result["errors"] + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, + ) + + +async def _test_user_flow_show_advanced_options( + hass: HomeAssistant, + *, + user_input_auth: dict[str, Any], + user_input_user: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Test config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_user or {}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert not result["errors"] + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, + ) + + +@pytest.mark.parametrize( + ("test_fn", "test_fn_args", "entry_data"), + [ + ( + _test_user_flow_show_advanced_options, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ( + _test_user_flow_show_advanced_options, + { + _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, + _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, + }, + VALID_ENTRY_DATA_SELF_HOSTED, + ), + ( + _test_user_flow, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ], + ids=["advanced_cloud", "advanced_self_hosted", "cloud"], +) +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] + | Callable[ + [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] + ], + test_fn_args: dict[str, Any], + entry_data: dict[str, Any], +) -> None: + """Test the user config flow.""" + result = await test_fn( + hass, + **test_fn_args, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == entry_data[CONF_USERNAME] + assert result["data"] == entry_data + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +def _cannot_connect_error(user_input: dict[str, Any]) -> str: + field = "base" + if CONF_OVERRIDE_MQTT_URL in user_input: + field = CONF_OVERRIDE_MQTT_URL + + return {field: "cannot_connect"} + + +@pytest.mark.parametrize( + ("side_effect_mqtt", "errors_mqtt"), + [ + (MqttError, _cannot_connect_error), + (InvalidAuthenticationError, lambda _: {"base": "invalid_auth"}), + (Exception, lambda _: {"base": "unknown"}), + ], + ids=["cannot_connect", "invalid_auth", "unknown"], +) +@pytest.mark.parametrize( + ("side_effect_rest", "reason_rest"), + [ + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], + ids=["cannot_connect", "invalid_auth", "unknown"], +) +@pytest.mark.parametrize( + ("test_fn", "test_fn_args", "entry_data"), + [ + ( + _test_user_flow_show_advanced_options, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ( + _test_user_flow_show_advanced_options, + { + _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, + _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, + }, + VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, + ), + ( + _test_user_flow, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ], + ids=["advanced_cloud", "advanced_self_hosted", "cloud"], +) +async def test_user_flow_raise_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + side_effect_rest: Exception, + reason_rest: str, + side_effect_mqtt: Exception, + errors_mqtt: Callable[[dict[str, Any]], str], + test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] + | Callable[ + [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] + ], + test_fn_args: dict[str, Any], + entry_data: dict[str, Any], +) -> None: + """Test handling error on library calls.""" + user_input_auth = test_fn_args[_TEST_FN_AUTH_ARG] + + # Authenticator raises error + mock_authenticator_authenticate.side_effect = side_effect_rest + result = await test_fn( + hass, + **test_fn_args, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": reason_rest} + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_not_called() + mock_setup_entry.assert_not_called() + + mock_authenticator_authenticate.reset_mock(side_effect=True) + + # MQTT raises error + mock_mqtt_client.verify_config.side_effect = side_effect_mqtt + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == errors_mqtt(user_input_auth) + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + mock_setup_entry.assert_not_called() + + mock_authenticator_authenticate.reset_mock(side_effect=True) + mock_mqtt_client.verify_config.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == entry_data[CONF_USERNAME] + assert result["data"] == entry_data + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +async def test_user_flow_self_hosted_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, +) -> None: + """Test handling selfhosted errors and custom ssl context.""" + + result = await _test_user_flow_show_advanced_options( + hass, + user_input_auth=VALID_ENTRY_DATA_SELF_HOSTED + | { + CONF_OVERRIDE_REST_URL: "bla://localhost:8000", + CONF_OVERRIDE_MQTT_URL: "mqtt://", + }, + user_input_user=_USER_STEP_SELF_HOSTED, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == { + CONF_OVERRIDE_REST_URL: "invalid_url_schema_override_rest_url", + CONF_OVERRIDE_MQTT_URL: "invalid_url", + } + mock_authenticator_authenticate.assert_not_called() + mock_mqtt_client.verify_config.assert_not_called() + mock_setup_entry.assert_not_called() + + # Check that the schema includes select box to disable ssl verification of mqtt + assert CONF_VERIFY_MQTT_CERTIFICATE in result["data_schema"].schema + + data = VALID_ENTRY_DATA_SELF_HOSTED | {CONF_VERIFY_MQTT_CERTIFICATE: False} + with patch( + "homeassistant.components.ecovacs.config_flow.create_mqtt_config", + wraps=create_mqtt_config, + ) as mock_create_mqtt_config: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, + ) + mock_create_mqtt_config.assert_called_once() + ssl_context = mock_create_mqtt_config.call_args[1]["ssl_context"] + assert isinstance(ssl_context, ssl.SSLContext) + assert ssl_context.verify_mode == ssl.CERT_NONE + assert ssl_context.check_hostname is False + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == data[CONF_USERNAME] + assert result["data"] == data + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +async def test_import_flow( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, +) -> None: + """Test importing yaml config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + mock_authenticator_authenticate.assert_called() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_ENTRY_DATA_CLOUD[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA_CLOUD + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + mock_setup_entry.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +async def test_import_flow_already_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test importing yaml config where entry already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_ENTRY_DATA_CLOUD) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + + +@pytest.mark.parametrize("show_advanced_options", [True, False]) +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_import_flow_error( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + side_effect: Exception, + reason: str, + show_advanced_options: bool, +) -> None: + """Test handling invalid connection.""" + mock_authenticator_authenticate.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + "show_advanced_options": show_advanced_options, + }, + data=IMPORT_DATA.copy(), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues + mock_authenticator_authenticate.assert_called() + + +@pytest.mark.parametrize("show_advanced_options", [True, False]) +@pytest.mark.parametrize( + ("reason", "user_input"), + [ + ("invalid_country_length", IMPORT_DATA | {CONF_COUNTRY: "too_long"}), + ("invalid_country_length", IMPORT_DATA | {CONF_COUNTRY: "a"}), # too short + ("invalid_continent_length", IMPORT_DATA | {CONF_CONTINENT: "too_long"}), + ("invalid_continent_length", IMPORT_DATA | {CONF_CONTINENT: "a"}), # too short + ("continent_not_match", IMPORT_DATA | {CONF_CONTINENT: "AA"}), + ], +) +async def test_import_flow_invalid_data( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + reason: str, + user_input: dict[str, Any], + show_advanced_options: bool, +) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + "show_advanced_options": show_advanced_options, + }, + data=user_input, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues diff --git a/tests/components/ecovacs/test_diagnostics.py b/tests/components/ecovacs/test_diagnostics.py new file mode 100644 index 00000000000..b025db43cc0 --- /dev/null +++ b/tests/components/ecovacs/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for diagnostics data.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + "mock_config_entry_data", + [VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED], + ids=lambda data: data[CONF_USERNAME], +) +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(exclude=props("entry_id")) diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py new file mode 100644 index 00000000000..e76001fbaeb --- /dev/null +++ b/tests/components/ecovacs/test_init.py @@ -0,0 +1,133 @@ +"""Test init of ecovacs.""" +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .const import IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_load_unload_config_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry = init_integration + assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN in hass.data + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert DOMAIN not in hass.data + + +@pytest.fixture +def mock_api_client(mock_authenticator: Mock) -> Mock: + """Mock the API client.""" + with patch( + "homeassistant.components.ecovacs.controller.ApiClient", + autospec=True, + ) as mock_api_client: + yield mock_api_client.return_value + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test the Ecovacs configuration entry not ready.""" + mock_api_client.get_devices.side_effect = DeebotError + + 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_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test auth error during setup.""" + mock_api_client.get_devices.side_effect = InvalidAuthenticationError + 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_ERROR + + +@pytest.mark.parametrize( + ("config", "config_entries_expected"), + [ + ({}, 0), + ({DOMAIN: IMPORT_DATA.copy()}, 1), + ], + ids=["no_config", "import_config"], +) +async def test_async_setup_import( + hass: HomeAssistant, + config: dict[str, Any], + config_entries_expected: int, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, +) -> None: + """Test async_setup config import.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected + assert mock_setup_entry.call_count == config_entries_expected + assert mock_authenticator_authenticate.call_count == config_entries_expected + assert mock_mqtt_client.verify_config.call_count == config_entries_expected + + +async def test_devices_in_dr( + device_registry: dr.DeviceRegistry, + controller: EcovacsController, + snapshot: SnapshotAssertion, +) -> None: + """Test all devices are in the device registry.""" + for device in controller.devices: + assert ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, device.device_info.did)} + ) + ) + assert device_entry == snapshot(name=device.device_info.did) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ("yna5x1", 25), + ], +) +async def test_all_entities_loaded( + hass: HomeAssistant, + device_fixture: str, + entities: int, +) -> None: + """Test that all entities are loaded together.""" + assert ( + hass.states.async_entity_ids_count() == entities + ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py new file mode 100644 index 00000000000..3d9607fc9af --- /dev/null +++ b/tests/components/ecovacs/test_number.py @@ -0,0 +1,149 @@ +"""Tests for Ecovacs select entities.""" + +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.commands.json import SetVolume +from deebot_client.events import Event, VolumeEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.number.const import ( + ATTR_VALUE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.NUMBER + + +@dataclass(frozen=True) +class NumberTestCase: + """Number test.""" + + entity_id: str + event: Event + current_state: str + set_value: int + command: Command + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + NumberTestCase( + "number.ozmo_950_volume", VolumeEvent(5, 11), "5", 10, SetVolume(10) + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[NumberTestCase], +) -> None: + """Test that number entity snapshots match.""" + device = controller.devices[0] + event_bus = device.events + + assert sorted(hass.states.async_entity_ids()) == sorted( + test.entity_id for test in tests + ) + for test_case in tests: + entity_id = test_case.entity_id + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + event_bus.notify(test_case.event) + await block_till_done(hass, event_bus) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + assert state.state == test_case.current_state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: test_case.set_value}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + ["number.ozmo_950_volume"], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_number_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default number entities.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_volume_maximum( + hass: HomeAssistant, + controller: EcovacsController, +) -> None: + """Test volume maximum.""" + device = controller.devices[0] + event_bus = device.events + entity_id = "number.ozmo_950_volume" + assert (state := hass.states.get(entity_id)) + assert state.attributes["max"] == 10 + + event_bus.notify(VolumeEvent(5, 20)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "5" + assert state.attributes["max"] == 20 + + event_bus.notify(VolumeEvent(10, None)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + assert state.attributes["max"] == 20 diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py new file mode 100644 index 00000000000..0d1a5d19116 --- /dev/null +++ b/tests/components/ecovacs/test_select.py @@ -0,0 +1,115 @@ +"""Tests for Ecovacs select entities.""" + +from deebot_client.command import Command +from deebot_client.commands.json import SetWaterInfo +from deebot_client.event_bus import EventBus +from deebot_client.events import WaterAmount, WaterInfoEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import select +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SELECT + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + await block_till_done(hass, event_bus) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "select.ozmo_950_water_flow_level", + ], + ), + ], +) +async def test_selects( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entity_ids: list[str], +) -> None: + """Test that select entity snapshots match.""" + assert entity_ids == hass.states.async_entity_ids() + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device = controller.devices[0] + await notify_events(hass, device.events) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "current_state", "set_state", "command"), + [ + ( + "yna5x1", + "select.ozmo_950_water_flow_level", + "ultrahigh", + "low", + SetWaterInfo(WaterAmount.LOW), + ), + ], +) +async def test_selects_change( + hass: HomeAssistant, + controller: EcovacsController, + entity_id: list[str], + current_state: str, + set_state: str, + command: Command, +) -> None: + """Test that changing select entities works.""" + device = controller.devices[0] + await notify_events(hass, device.events) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == current_state + + device._execute_command.reset_mock() + await hass.services.async_call( + select.DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: set_state}, + blocking=True, + ) + device._execute_command.assert_called_with(command) diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py new file mode 100644 index 00000000000..78755668f0f --- /dev/null +++ b/tests/components/ecovacs/test_sensor.py @@ -0,0 +1,126 @@ +"""Tests for Ecovacs sensors.""" + +from deebot_client.event_bus import EventBus +from deebot_client.events import ( + BatteryEvent, + ErrorEvent, + LifeSpan, + LifeSpanEvent, + NetworkInfoEvent, + StatsEvent, + TotalStatsEvent, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SENSOR + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(StatsEvent(10, 300, "spotArea")) + event_bus.notify(TotalStatsEvent(60, 144000, 123)) + event_bus.notify(BatteryEvent(100)) + event_bus.notify(BatteryEvent(100)) + event_bus.notify( + NetworkInfoEvent("192.168.0.10", "Testnetwork", -62, "AA:BB:CC:DD:EE:FF") + ) + event_bus.notify(LifeSpanEvent(LifeSpan.BRUSH, 80, 60 * 60)) + event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) + event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) + event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) + await block_till_done(hass, event_bus) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "sensor.ozmo_950_area_cleaned", + "sensor.ozmo_950_cleaning_duration", + "sensor.ozmo_950_total_area_cleaned", + "sensor.ozmo_950_total_cleaning_duration", + "sensor.ozmo_950_total_cleanings", + "sensor.ozmo_950_battery", + "sensor.ozmo_950_ip_address", + "sensor.ozmo_950_wi_fi_rssi", + "sensor.ozmo_950_wi_fi_ssid", + "sensor.ozmo_950_main_brush_lifespan", + "sensor.ozmo_950_filter_lifespan", + "sensor.ozmo_950_side_brushes_lifespan", + "sensor.ozmo_950_error", + ], + ), + ], +) +async def test_sensors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entity_ids: list[str], +) -> None: + """Test that sensor entity snapshots match.""" + assert entity_ids == hass.states.async_entity_ids() + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device = controller.devices[0] + await notify_events(hass, device.events) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "sensor.ozmo_950_error", + "sensor.ozmo_950_ip_address", + "sensor.ozmo_950_wi_fi_rssi", + "sensor.ozmo_950_wi_fi_ssid", + ], + ), + ], +) +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default sensors.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py new file mode 100644 index 00000000000..35d2f487b95 --- /dev/null +++ b/tests/components/ecovacs/test_switch.py @@ -0,0 +1,157 @@ +"""Tests for Ecovacs select entities.""" + +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.commands.json import ( + SetAdvancedMode, + SetCarpetAutoFanBoost, + SetContinuousCleaning, +) +from deebot_client.events import ( + AdvancedModeEvent, + CarpetAutoFanBoostEvent, + ContinuousCleaningEvent, + Event, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.switch.const import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SWITCH + + +@dataclass(frozen=True) +class SwitchTestCase: + """Switch test.""" + + entity_id: str + event: Event + command: type[Command] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + SwitchTestCase( + "switch.ozmo_950_advanced_mode", + AdvancedModeEvent(True), + SetAdvancedMode, + ), + SwitchTestCase( + "switch.ozmo_950_continuous_cleaning", + ContinuousCleaningEvent(True), + SetContinuousCleaning, + ), + SwitchTestCase( + "switch.ozmo_950_carpet_auto_boost_suction", + CarpetAutoFanBoostEvent(True), + SetCarpetAutoFanBoost, + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_switch_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[SwitchTestCase], +) -> None: + """Test switch entities.""" + device = controller.devices[0] + event_bus = device.events + + assert hass.states.async_entity_ids() == [test.entity_id for test in tests] + for test_case in tests: + entity_id = test_case.entity_id + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_OFF + + event_bus.notify(test_case.event) + await block_till_done(hass, event_bus) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + assert state.state == STATE_ON + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(False)) + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(True)) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "switch.ozmo_950_advanced_mode", + "switch.ozmo_950_continuous_cleaning", + "switch.ozmo_950_carpet_auto_boost_suction", + ], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_switch_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default switch entities.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/util.py b/tests/components/ecovacs/util.py new file mode 100644 index 00000000000..73762128202 --- /dev/null +++ b/tests/components/ecovacs/util.py @@ -0,0 +1,21 @@ +"""Ecovacs test util.""" +import asyncio + +from deebot_client.event_bus import EventBus +from deebot_client.events import Event + +from homeassistant.core import HomeAssistant + + +async def block_till_done(hass: HomeAssistant, event_bus: EventBus) -> None: + """Block till done.""" + await asyncio.gather(*event_bus._tasks) + await hass.async_block_till_done() + + +async def notify_and_wait( + hass: HomeAssistant, event_bus: EventBus, event: Event +) -> None: + """Block till done.""" + event_bus.notify(event) + await block_till_done(hass, event_bus) diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py index 5d77acc6838..3780bcb5494 100644 --- a/tests/components/efergy/__init__.py +++ b/tests/components/efergy/__init__.py @@ -40,7 +40,6 @@ async def init_integration( """Set up the Efergy integration in Home Assistant.""" entry = create_entry(hass, token=token) await mock_responses(hass, aioclient_mock, token=token, error=error) - 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/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index f7e60e975f8..684fef24240 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -6,7 +6,7 @@ from time import time from unittest.mock import AsyncMock, patch import zoneinfo -from electrickiwi_api.model import Hop, HopIntervals +from electrickiwi_api.model import AccountBalance, Hop, HopIntervals import pytest from homeassistant.components.application_credentials import ( @@ -43,14 +43,18 @@ def component_setup( async def _setup_func() -> bool: assert await async_setup_component(hass, "application_credentials", {}) + await hass.async_block_till_done() await async_import_client_credential( hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), DOMAIN, ) + await hass.async_block_till_done() config_entry.add_to_hass(hass) - return await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result return _setup_func @@ -113,4 +117,9 @@ def ek_api() -> YieldFixture: mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( load_json_value_fixture("get_hop.json", DOMAIN) ) + mock_ek_api.return_value.get_account_balance.return_value = ( + AccountBalance.from_dict( + load_json_value_fixture("account_balance.json", DOMAIN) + ) + ) yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json new file mode 100644 index 00000000000..25bc57784ee --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_balance.json @@ -0,0 +1,28 @@ +{ + "data": { + "connections": [ + { + "hop_percentage": "3.5", + "id": 3, + "running_balance": "184.09", + "start_date": "2020-10-04", + "unbilled_days": 15 + } + ], + "last_billed_amount": "-66.31", + "last_billed_date": "2020-10-03", + "next_billing_date": "2020-11-03", + "is_prepay": "N", + "summary": { + "credits": "0.0", + "electricity_used": "184.09", + "other_charges": "0.00", + "payments": "-220.0" + }, + "total_account_balance": "-102.22", + "total_billing_days": 30, + "total_running_balance": "184.09", + "type": "account_running_balance" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index ef268735334..4961f5fdcd4 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -9,7 +9,11 @@ import pytest from homeassistant.components.electric_kiwi.const import ATTRIBUTION from homeassistant.components.electric_kiwi.sensor import _check_and_move_time -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant @@ -65,6 +69,58 @@ async def test_hop_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP +@pytest.mark.parametrize( + ("sensor", "sensor_state", "device_class", "state_class"), + [ + ( + "sensor.total_running_balance", + "184.09", + SensorDeviceClass.MONETARY, + SensorStateClass.TOTAL, + ), + ( + "sensor.total_current_balance", + "-102.22", + SensorDeviceClass.MONETARY, + SensorStateClass.TOTAL, + ), + ( + "sensor.next_billing_date", + "2020-11-03T00:00:00", + SensorDeviceClass.DATE, + None, + ), + ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ], +) +async def test_account_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ek_api: YieldFixture, + ek_auth: YieldFixture, + entity_registry: EntityRegistry, + component_setup: ComponentSetup, + sensor: str, + sensor_state: str, + device_class: str, + state_class: str, +) -> None: + """Test Account sensors for the Electric Kiwi integration.""" + + assert await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get(sensor) + assert entity + + state = hass.states.get(sensor) + assert state + assert state.state == sensor_state + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class + assert state.attributes.get(ATTR_STATE_CLASS) == state_class + + async def test_check_and_move_time(ek_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" hop = await ek_api(Mock()).get_hop() diff --git a/tests/components/elvia/__init__.py b/tests/components/elvia/__init__.py new file mode 100644 index 00000000000..4a0d145e730 --- /dev/null +++ b/tests/components/elvia/__init__.py @@ -0,0 +1 @@ +"""Tests for the Elvia integration.""" diff --git a/tests/components/elvia/conftest.py b/tests/components/elvia/conftest.py new file mode 100644 index 00000000000..a2a10e67893 --- /dev/null +++ b/tests/components/elvia/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Elvia 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.elvia.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/elvia/test_config_flow.py b/tests/components/elvia/test_config_flow.py new file mode 100644 index 00000000000..630aca4f16c --- /dev/null +++ b/tests/components/elvia/test_config_flow.py @@ -0,0 +1,237 @@ +"""Test the Elvia config flow.""" +from unittest.mock import AsyncMock, patch + +from elvia import error as ElviaError +import pytest + +from homeassistant import config_entries +from homeassistant.components.elvia.const import CONF_METERING_POINT_ID, DOMAIN +from homeassistant.components.recorder.core import Recorder +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType, UnknownFlow + +from tests.common import MockConfigEntry + +TEST_API_TOKEN = "xxx-xxx-xxx-xxx" + + +async def test_single_metering_point( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with a single metering point.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": [{"meteringPointId": "1234"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "1234" + assert result["data"] == { + CONF_API_TOKEN: TEST_API_TOKEN, + CONF_METERING_POINT_ID: "1234", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_metering_points( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with multiple metering points.""" + 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( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={ + "meteringpoints": [ + {"meteringPointId": "1234"}, + {"meteringPointId": "5678"}, + ] + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_meter" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_METERING_POINT_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "5678" + assert result["data"] == { + CONF_API_TOKEN: TEST_API_TOKEN, + CONF_METERING_POINT_ID: "5678", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_metering_points( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with no metering points.""" + 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( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": []}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_metering_points" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_bad_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with no metering points.""" + 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( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_metering_points" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_abort_when_metering_point_id_exist( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort when the metering point ID exist.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234", + ) + 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"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": [{"meteringPointId": "1234"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "metering_point_id_already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("side_effect", "base_error"), + ( + (ElviaError.ElviaException("Boom"), "unknown"), + (ElviaError.AuthError("Boom", 403, {}, ""), "invalid_auth"), + (ElviaError.ElviaServerException("Boom", 500, {}, ""), "unknown"), + (ElviaError.ElviaClientException("Boom"), "unknown"), + ), +) +async def test_form_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + side_effect: Exception, + base_error: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + # Simulate that the user gives up and closes the window... + 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"]) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 185f65aa892..ed0a60dfe94 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ from pyenphase import ( EnvoySystemProduction, EnvoyTokenAuth, ) +from pyenphase.const import PhaseNames, SupportedFeatures +from pyenphase.models.meters import CtType, EnvoyPhaseMode import pytest from homeassistant.components.enphase_envoy import DOMAIN @@ -53,6 +55,18 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth + mock_envoy.supported_features = SupportedFeatures( + SupportedFeatures.INVERTERS + | SupportedFeatures.PRODUCTION + | SupportedFeatures.PRODUCTION + | SupportedFeatures.METERING + | SupportedFeatures.THREEPHASE + ) + mock_envoy.phase_mode = EnvoyPhaseMode.THREE + mock_envoy.phase_count = 3 + mock_envoy.active_phase_count = 3 + mock_envoy.ct_meter_count = 2 + mock_envoy.consumption_meter_type = CtType.NET_CONSUMPTION mock_envoy.data = EnvoyData( system_consumption=EnvoySystemConsumption( watt_hours_last_7_days=1234, @@ -66,6 +80,46 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): watt_hours_today=1234, watts_now=1234, ), + system_consumption_phases={ + PhaseNames.PHASE_1: EnvoySystemConsumption( + watt_hours_last_7_days=1321, + watt_hours_lifetime=1322, + watt_hours_today=1323, + watts_now=1324, + ), + PhaseNames.PHASE_2: EnvoySystemConsumption( + watt_hours_last_7_days=2321, + watt_hours_lifetime=2322, + watt_hours_today=2323, + watts_now=2324, + ), + PhaseNames.PHASE_3: EnvoySystemConsumption( + watt_hours_last_7_days=3321, + watt_hours_lifetime=3322, + watt_hours_today=3323, + watts_now=3324, + ), + }, + system_production_phases={ + PhaseNames.PHASE_1: EnvoySystemProduction( + watt_hours_last_7_days=1231, + watt_hours_lifetime=1232, + watt_hours_today=1233, + watts_now=1234, + ), + PhaseNames.PHASE_2: EnvoySystemProduction( + watt_hours_last_7_days=2231, + watt_hours_lifetime=2232, + watt_hours_today=2233, + watts_now=2234, + ), + PhaseNames.PHASE_3: EnvoySystemProduction( + watt_hours_last_7_days=3231, + watt_hours_lifetime=3232, + watt_hours_today=3233, + watts_now=3234, + ), + }, inverters={ "1": EnvoyInverter( serial_number="1", diff --git a/tests/components/epion/__init__.py b/tests/components/epion/__init__.py new file mode 100644 index 00000000000..2327d2fa848 --- /dev/null +++ b/tests/components/epion/__init__.py @@ -0,0 +1 @@ +"""Tests for the Epion component.""" diff --git a/tests/components/epion/conftest.py b/tests/components/epion/conftest.py new file mode 100644 index 00000000000..2290d0d4c8f --- /dev/null +++ b/tests/components/epion/conftest.py @@ -0,0 +1,25 @@ +"""Epion tests configuration.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.common import load_json_object_fixture + + +@pytest.fixture +def mock_epion(): + """Build a fixture for the Epion API that connects successfully and returns one device.""" + current_one_device_data = load_json_object_fixture( + "epion/get_current_one_device.json" + ) + mock_epion_api = MagicMock() + with patch( + "homeassistant.components.epion.config_flow.Epion", + return_value=mock_epion_api, + ) as mock_epion_api, patch( + "homeassistant.components.epion.Epion", + return_value=mock_epion_api, + ): + mock_epion_api.return_value.get_current.return_value = current_one_device_data + yield mock_epion_api diff --git a/tests/components/epion/fixtures/get_current_one_device.json b/tests/components/epion/fixtures/get_current_one_device.json new file mode 100644 index 00000000000..4cfeb673bfe --- /dev/null +++ b/tests/components/epion/fixtures/get_current_one_device.json @@ -0,0 +1,15 @@ +{ + "devices": [ + { + "deviceId": "abc", + "deviceName": "Test Device", + "co2": 500, + "temperature": 12.34, + "humidity": 34.56, + "pressure": 1010.101, + "lastMeasurement": 1705329293171, + "fwVersion": "1.2.3" + } + ], + "accountId": "account-dupe-123" +} diff --git a/tests/components/epion/test_config_flow.py b/tests/components/epion/test_config_flow.py new file mode 100644 index 00000000000..50666d52336 --- /dev/null +++ b/tests/components/epion/test_config_flow.py @@ -0,0 +1,115 @@ +"""Tests for the Epion config flow.""" +from unittest.mock import MagicMock, patch + +from epion import EpionAuthenticationError, EpionConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.epion.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 + +API_KEY = "test-key-123" + + +async def test_user_flow(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test we can handle a regular successflow setup flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (EpionAuthenticationError("Invalid auth"), "invalid_auth"), + (EpionConnectionError("Timeout error"), "cannot_connect"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str, mock_epion: MagicMock +) -> None: + """Test we can handle Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_epion.return_value.get_current.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_epion.return_value.get_current.side_effect = None + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test duplicate setup handling.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + }, + unique_id="account-dupe-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + 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/esphome/conftest.py b/tests/components/esphome/conftest.py index 9182e021a65..ac9d9235917 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -14,6 +14,7 @@ from aioesphomeapi import ( DeviceInfo, EntityInfo, EntityState, + HomeassistantServiceCall, ReconnectLogic, UserService, ) @@ -89,6 +90,10 @@ class BaseMockReconnectLogic(ReconnectLogic): self._cancel_connect("forced disconnect from test") self._is_stopped = True + async def stop(self) -> None: + """Stop the reconnect logic.""" + self.stop_callback() + @pytest.fixture def mock_device_info() -> DeviceInfo: @@ -172,17 +177,32 @@ async def mock_dashboard(hass): class MockESPHomeDevice: """Mock an esphome device.""" - def __init__(self, entry: MockConfigEntry) -> None: + def __init__(self, entry: MockConfigEntry, client: APIClient) -> None: """Init the mock.""" self.entry = entry + self.client = client 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] + self.home_assistant_state_subscription_callback: Callable[ + [str, str | None], None + ] def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """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) @@ -203,6 +223,19 @@ class MockESPHomeDevice: """Mock connecting.""" await self.on_connect() + def set_home_assistant_state_subscription_callback( + self, + on_state_sub: Callable[[str, str | None], None], + ) -> None: + """Set the state call callback.""" + self.home_assistant_state_subscription_callback = on_state_sub + + def mock_home_assistant_state_subscription( + self, entity_id: str, attribute: str | None + ) -> None: + """Mock a state subscription.""" + self.home_assistant_state_subscription_callback(entity_id, attribute) + async def _mock_generic_device_entry( hass: HomeAssistant, @@ -226,7 +259,7 @@ async def _mock_generic_device_entry( ) entry.add_to_hass(hass) - mock_device = MockESPHomeDevice(entry) + mock_device = MockESPHomeDevice(entry, mock_client) default_device_info = { "name": "test", @@ -242,12 +275,26 @@ async def _mock_generic_device_entry( 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) + + async def _subscribe_home_assistant_states( + on_state_sub: Callable[[str, str | None], None], + ) -> None: + """Subscribe to home assistant states.""" + mock_device.set_home_assistant_state_subscription_callback(on_state_sub) + 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 + mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states try_connect_done = Event() diff --git a/tests/components/esphome/snapshots/test_climate.ambr b/tests/components/esphome/snapshots/test_climate.ambr new file mode 100644 index 00000000000..69d721ecb94 --- /dev/null +++ b/tests/components/esphome/snapshots/test_climate.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_climate_entity_attributes[climate-entity-attributes] + ReadOnlyDict({ + 'current_temperature': 30, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'high', + 'fan1', + 'fan2', + ]), + 'friendly_name': 'Test my climate', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'away', + 'activity', + 'preset1', + 'preset2', + ]), + 'supported_features': , + 'swing_mode': 'both', + 'swing_modes': list([ + 'both', + 'off', + ]), + 'target_temp_step': 2, + 'temperature': 20, + }) +# --- diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 065890fd623..dbdee826137 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 ( @@ -13,9 +14,11 @@ from aioesphomeapi import ( ClimateState, ClimateSwingMode, ) +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, @@ -377,3 +380,112 @@ async def test_climate_entity_with_humidity( ) 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 + + +async def test_climate_entity_attributes( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, + snapshot: SnapshotAssertion, +) -> None: + """Test a climate entity sets correct attributes.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + visual_target_temperature_step=2, + visual_current_temperature_step=2, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_fan_modes=[ClimateFanMode.LOW, ClimateFanMode.HIGH], + supported_modes=[ + ClimateMode.COOL, + ClimateMode.HEAT, + ClimateMode.AUTO, + ClimateMode.OFF, + ], + supported_presets=[ClimatePreset.AWAY, ClimatePreset.ACTIVITY], + supported_custom_presets=["preset1", "preset2"], + supported_custom_fan_modes=["fan1", "fan2"], + supported_swing_modes=[ClimateSwingMode.BOTH, ClimateSwingMode.OFF], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.COOL, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + ) + ] + 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.COOL + assert state.attributes == snapshot(name="climate-entity-attributes") diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index d8732ea0453..320b20832c8 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_entity.py b/tests/components/esphome/test_entity.py index 9a5cb441f28..03fd21c32f8 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -1,6 +1,7 @@ """Test ESPHome binary sensors.""" from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import AsyncMock from aioesphomeapi import ( APIClient, @@ -21,6 +22,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import MockESPHomeDevice @@ -34,7 +36,8 @@ async def test_entities_removed( Awaitable[MockESPHomeDevice], ], ) -> None: - """Test a generic binary_sensor where has_state is false.""" + """Test entities are removed when static info changes.""" + ent_reg = er.async_get(hass) entity_info = [ BinarySensorInfo( object_id="mybinary_sensor", @@ -80,6 +83,8 @@ async def test_entities_removed( assert state.attributes[ATTR_RESTORED] is True state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True entity_info = [ @@ -106,11 +111,128 @@ async def test_entities_removed( assert state.state == STATE_ON state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is None + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 +async def test_entities_removed_after_reload( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test entities and their registry entry are removed when static info changes after a reload.""" + ent_reg = er.async_get(hass) + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=2, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 + + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 + + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert ATTR_RESTORED not in state.attributes + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert ATTR_RESTORED not in state.attributes + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + mock_device.client.list_entities_services = AsyncMock( + return_value=(entity_info, user_service) + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is None + + await hass.async_block_till_done() + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is None + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 99058ad3ed4..fc63508a836 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( 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.ONOFF] + + 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 69ed653d75b..e3b5c2aa08d 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,25 +2,237 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, call -from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService +from aioesphomeapi import ( + APIClient, + APIConnectionError, + 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.helpers import device_registry as dr, 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.""" + await async_setup_component(hass, "tag", {}) + 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 scanning a tag + events = async_capture_events(hass, "tag_scanned") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.tag_scanned", + is_event=True, + data={"tag_id": "1234"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 1 + event = events[0] + assert event.event_type == "tag_scanned" + assert event.data["tag_id"] == "1234" + 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( @@ -317,8 +529,11 @@ async def test_connection_aborted_wrong_device( "with mac address `11:22:33:44:55:ab`" in caplog.text ) + assert "Error getting setting up connection for" not in caplog.text + assert len(mock_client.disconnect.mock_calls) == 1 + mock_client.disconnect.reset_mock() caplog.clear() - # Make sure discovery triggers a reconnect to the correct device + # Make sure discovery triggers a reconnect service_info = dhcp.DhcpServiceInfo( ip="192.168.43.184", hostname="test", @@ -340,6 +555,98 @@ async def test_connection_aborted_wrong_device( assert "Unexpected device found at" not in caplog.text +async def test_failure_during_connect( + hass: HomeAssistant, + mock_client: APIClient, + mock_zeroconf: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we disconnect when there is a failure during connection setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock(side_effect=APIConnectionError("fail")) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "Error getting setting up connection for" in caplog.text + # Ensure we disconnect so that the reconnect logic is triggered + assert len(mock_client.disconnect.mock_calls) == 1 + + +async def test_state_subscription( + mock_client: APIClient, + hass: HomeAssistant, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test ESPHome subscribes to state changes.""" + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 3.0}) + device.mock_home_assistant_state_subscription("binary_sensor.test", None) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "on") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "off", {"bool": True, "float": 3.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "off") + ] + mock_client.send_home_assistant_state.reset_mock() + device.mock_home_assistant_state_subscription("binary_sensor.test", "bool") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "bool", "on") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "off", {"bool": False, "float": 3.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "bool", "off") + ] + mock_client.send_home_assistant_state.reset_mock() + device.mock_home_assistant_state_subscription("binary_sensor.test", "float") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "float", "3.0") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 4.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "on"), + call("binary_sensor.test", "bool", "on"), + call("binary_sensor.test", "float", "4.0"), + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "on", {}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [] + hass.states.async_remove("binary_sensor.test") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [] + + async def test_debug_logging( mock_client: APIClient, hass: HomeAssistant, @@ -374,3 +681,348 @@ async def test_debug_logging( ) 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() + + +async def test_esphome_device_with_suggested_area( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with suggested area.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"suggested_area": "kitchen"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.suggested_area == "kitchen" + + +async def test_esphome_device_with_project( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a project.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"project_name": "mfr.model", "project_version": "2.2.2"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.manufacturer == "mfr" + assert dev.model == "model" + assert dev.hw_version == "2.2.2" + + +async def test_esphome_device_with_manufacturer( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a manufacturer.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"manufacturer": "acme"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.manufacturer == "acme" + + +async def test_esphome_device_with_web_server( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a web server.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"webserver_port": 80}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.configuration_url == "http://test.local:80" + + +async def test_esphome_device_with_compilation_time( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a compilation_time.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"compilation_time": "comp_time"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert "comp_time" in dev.sw_version diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 9ab00421cbc..d267a13145f 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -9,7 +9,13 @@ import pytest 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 @@ -370,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 38a33bfdec2..f6665c4ad91 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -70,6 +70,19 @@ def voice_assistant_udp_server_v2( return voice_assistant_udp_server(entry=mock_voice_assistant_v2_entry) +@pytest.fixture +def test_wav() -> bytes: + """Return one second of empty WAV audio.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + return wav_io.getvalue() + + async def test_pipeline_events( hass: HomeAssistant, voice_assistant_udp_server_v1: VoiceAssistantUDPServer, @@ -241,11 +254,13 @@ async def test_udp_server_multiple( ): await voice_assistant_udp_server_v1.start_server() - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), pytest.raises(RuntimeError): - pass + with ( + patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), + pytest.raises(RuntimeError), + ): await voice_assistant_udp_server_v1.start_server() @@ -257,10 +272,13 @@ async def test_udp_server_after_stopped( ) -> None: """Test that the UDP server raises an error if started after stopped.""" voice_assistant_udp_server_v1.close() - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), pytest.raises(RuntimeError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), + pytest.raises(RuntimeError), + ): await voice_assistant_udp_server_v1.start_server() @@ -362,35 +380,33 @@ async def test_send_tts_not_called_when_empty( async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, + test_wav, ) -> None: """Test the UDP server calls sendto to transmit audio data to device.""" - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(16000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(bytes(_ONE_SECOND)) - - wav_bytes = wav_io.getvalue() - with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), + return_value=("wav", test_wav), ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) - - voice_assistant_udp_server_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, + with patch.object( + voice_assistant_udp_server_v2.transport, "is_closing", return_value=False + ): + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": { + "media_id": _TEST_MEDIA_ID, + "url": _TEST_OUTPUT_URL, + } + }, + ) ) - ) - await voice_assistant_udp_server_v2._tts_done.wait() + await voice_assistant_udp_server_v2._tts_done.wait() - voice_assistant_udp_server_v2.transport.sendto.assert_called() + voice_assistant_udp_server_v2.transport.sendto.assert_called() async def test_send_tts_wrong_sample_rate( @@ -400,17 +416,20 @@ async def test_send_tts_wrong_sample_rate( """Test the UDP server calls sendto to transmit audio data to device.""" with io.BytesIO() as wav_io: with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(22050) # should be 16000 + wav_file.setframerate(22050) wav_file.setsampwidth(2) wav_file.setnchannels(1) wav_file.writeframes(bytes(_ONE_SECOND)) wav_bytes = wav_io.getvalue() - - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), - ), pytest.raises(ValueError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", wav_bytes), + ), + pytest.raises(ValueError), + ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) voice_assistant_udp_server_v2._event_callback( @@ -431,10 +450,14 @@ async def test_send_tts_wrong_format( voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test that only WAV audio will be streamed.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("raw", bytes(1024)), - ), pytest.raises(ValueError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ), + pytest.raises(ValueError), + ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) voice_assistant_udp_server_v2._event_callback( @@ -450,6 +473,33 @@ async def test_send_tts_wrong_format( await voice_assistant_udp_server_v2._tts_task # raises ValueError +async def test_send_tts_not_started( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, + test_wav, +) -> None: + """Test the UDP server does not call sendto when not started.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", test_wav), + ): + voice_assistant_udp_server_v2.started = False + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + await voice_assistant_udp_server_v2._tts_done.wait() + + voice_assistant_udp_server_v2.transport.sendto.assert_not_called() + + async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, @@ -459,11 +509,12 @@ async def test_wake_word( async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): assert start_stage == PipelineStage.WAKE_WORD - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), patch( - "asyncio.Event.wait" # TTS wait event + with ( + patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch("asyncio.Event.wait"), # TTS wait event ): voice_assistant_udp_server_v2.transport = Mock() @@ -515,10 +566,15 @@ async def test_wake_word_abort_exception( async def async_pipeline_from_audio_stream(*args, **kwargs): raise WakeWordDetectionAborted - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: + with ( + patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch.object( + voice_assistant_udp_server_v2, "handle_event" + ) as mock_handle_event, + ): voice_assistant_udp_server_v2.transport = Mock() await voice_assistant_udp_server_v2.run_pipeline( diff --git a/tests/components/facebox/__init__.py b/tests/components/facebox/__init__.py deleted file mode 100644 index fbbb6640e40..00000000000 --- a/tests/components/facebox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the facebox component.""" diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py deleted file mode 100644 index 4c6497b975b..00000000000 --- a/tests/components/facebox/test_image_processing.py +++ /dev/null @@ -1,341 +0,0 @@ -"""The tests for the facebox component.""" -from http import HTTPStatus -from unittest.mock import Mock, mock_open, patch - -import pytest -import requests -import requests_mock - -import homeassistant.components.facebox.image_processing as fb -import homeassistant.components.image_processing as ip -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_NAME, - CONF_FRIENDLY_NAME, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.setup import async_setup_component - -MOCK_IP = "192.168.0.1" -MOCK_PORT = "8080" - -# Mock data returned by the facebox API. -MOCK_BOX_ID = "b893cc4f7fd6" -MOCK_ERROR_NO_FACE = "No face found" -MOCK_FACE = { - "confidence": 0.5812028911604818, - "id": "john.jpg", - "matched": True, - "name": "John Lennon", - "rect": {"height": 75, "left": 63, "top": 262, "width": 74}, -} - -MOCK_FILE_PATH = "/images/mock.jpg" - -MOCK_HEALTH = { - "success": True, - "hostname": "b893cc4f7fd6", - "metadata": {"boxname": "facebox", "build": "development"}, - "errors": [], -} - -MOCK_JSON = {"facesCount": 1, "success": True, "faces": [MOCK_FACE]} - -MOCK_NAME = "mock_name" -MOCK_USERNAME = "mock_username" -MOCK_PASSWORD = "mock_password" - -# Faces data after parsing. -PARSED_FACES = [ - { - fb.FACEBOX_NAME: "John Lennon", - fb.ATTR_IMAGE_ID: "john.jpg", - fb.ATTR_CONFIDENCE: 58.12, - fb.ATTR_MATCHED: True, - fb.ATTR_BOUNDING_BOX: {"height": 75, "left": 63, "top": 262, "width": 74}, - } -] - -MATCHED_FACES = {"John Lennon": 58.12} - -VALID_ENTITY_ID = "image_processing.facebox_demo_camera" -VALID_CONFIG = { - ip.DOMAIN: { - "platform": "facebox", - CONF_IP_ADDRESS: MOCK_IP, - CONF_PORT: MOCK_PORT, - ip.CONF_SOURCE: {ip.CONF_ENTITY_ID: "camera.demo_camera"}, - }, - "camera": {"platform": "demo"}, -} - - -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) - - -@pytest.fixture -def mock_healthybox(): - """Mock fb.check_box_health.""" - check_box_health = ( - "homeassistant.components.facebox.image_processing.check_box_health" - ) - with patch(check_box_health, return_value=MOCK_BOX_ID) as _mock_healthybox: - yield _mock_healthybox - - -@pytest.fixture -def mock_isfile(): - """Mock os.path.isfile.""" - with patch( - "homeassistant.components.facebox.image_processing.cv.isfile", return_value=True - ) as _mock_isfile: - yield _mock_isfile - - -@pytest.fixture -def mock_image(): - """Return a mock camera image.""" - with patch( - "homeassistant.components.demo.camera.DemoCamera.camera_image", - return_value=b"Test", - ) as image: - yield image - - -@pytest.fixture -def mock_open_file(): - """Mock open.""" - mopen = mock_open() - with patch( - "homeassistant.components.facebox.image_processing.open", mopen, create=True - ) as _mock_open: - yield _mock_open - - -def test_check_box_health(caplog: pytest.LogCaptureFixture) -> None: - """Test check box health.""" - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/healthz" - mock_req.get(url, status_code=HTTPStatus.OK, json=MOCK_HEALTH) - assert fb.check_box_health(url, "user", "pass") == MOCK_BOX_ID - - mock_req.get(url, status_code=HTTPStatus.UNAUTHORIZED) - assert fb.check_box_health(url, None, None) is None - assert "AuthenticationError on facebox" in caplog.text - - mock_req.get(url, exc=requests.exceptions.ConnectTimeout) - fb.check_box_health(url, None, None) - assert "ConnectionError: Is facebox running?" in caplog.text - - -def test_encode_image() -> None: - """Test that binary data is encoded correctly.""" - assert fb.encode_image(b"test") == "dGVzdA==" - - -def test_get_matched_faces() -> None: - """Test that matched_faces are parsed correctly.""" - assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES - - -def test_parse_faces() -> None: - """Test parsing of raw face data, and generation of matched_faces.""" - assert fb.parse_faces(MOCK_JSON["faces"]) == PARSED_FACES - - -@patch("os.access", Mock(return_value=False)) -def test_valid_file_path() -> None: - """Test that an invalid file_path is caught.""" - assert not fb.valid_file_path("test_path") - - -async def test_setup_platform(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - -async def test_setup_platform_with_auth(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity and auth.""" - valid_config_auth = VALID_CONFIG.copy() - valid_config_auth[ip.DOMAIN][CONF_USERNAME] = MOCK_USERNAME - valid_config_auth[ip.DOMAIN][CONF_PASSWORD] = MOCK_PASSWORD - - await async_setup_component(hass, ip.DOMAIN, valid_config_auth) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - -async def test_process_image(hass: HomeAssistant, mock_healthybox, mock_image) -> None: - """Test successful processing of an image.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - face_events = [] - - @callback - def mock_face_event(event): - """Mock event.""" - face_events.append(event) - - hass.bus.async_listen("image_processing.detect_face", mock_face_event) - - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.post(url, json=MOCK_JSON) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - - state = hass.states.get(VALID_ENTITY_ID) - assert state.state == "1" - assert state.attributes.get("matched_faces") == MATCHED_FACES - assert state.attributes.get("total_matched_faces") == 1 - - PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. - assert state.attributes.get("faces") == PARSED_FACES - assert state.attributes.get(CONF_FRIENDLY_NAME) == "facebox demo_camera" - - assert len(face_events) == 1 - assert face_events[0].data[ATTR_NAME] == PARSED_FACES[0][ATTR_NAME] - assert ( - face_events[0].data[fb.ATTR_CONFIDENCE] == PARSED_FACES[0][fb.ATTR_CONFIDENCE] - ) - assert face_events[0].data[ATTR_ENTITY_ID] == VALID_ENTITY_ID - assert face_events[0].data[fb.ATTR_IMAGE_ID] == PARSED_FACES[0][fb.ATTR_IMAGE_ID] - assert ( - face_events[0].data[fb.ATTR_BOUNDING_BOX] - == PARSED_FACES[0][fb.ATTR_BOUNDING_BOX] - ) - - -async def test_process_image_errors( - hass: HomeAssistant, mock_healthybox, mock_image, caplog: pytest.LogCaptureFixture -) -> None: - """Test process_image errors.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - # Test connection error. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.register_uri("POST", url, exc=requests.exceptions.ConnectTimeout) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - assert "ConnectionError: Is facebox running?" in caplog.text - - state = hass.states.get(VALID_ENTITY_ID) - assert state.state == STATE_UNKNOWN - assert state.attributes.get("faces") == [] - assert state.attributes.get("matched_faces") == {} - - # Now test with bad auth. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.register_uri("POST", url, status_code=HTTPStatus.UNAUTHORIZED) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - assert "AuthenticationError on facebox" in caplog.text - - -async def test_teach_service( - hass: HomeAssistant, - mock_healthybox, - mock_image, - mock_isfile, - mock_open_file, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test teaching of facebox.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - # Patch out 'is_allowed_path' as the mock files aren't allowed - hass.config.is_allowed_path = Mock(return_value=True) - - # Test successful teach. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.OK) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - - # Now test with bad auth. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.UNAUTHORIZED) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert "AuthenticationError on facebox" in caplog.text - - # Now test the failed teaching. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.BAD_REQUEST, text=MOCK_ERROR_NO_FACE) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert MOCK_ERROR_NO_FACE in caplog.text - - # Now test connection error. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, exc=requests.exceptions.ConnectTimeout) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert "ConnectionError: Is facebox running?" in caplog.text - - -async def test_setup_platform_with_name(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity and a name.""" - named_entity_id = f"image_processing.{MOCK_NAME}" - - valid_config_named = VALID_CONFIG.copy() - valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME - - await async_setup_component(hass, ip.DOMAIN, valid_config_named) - await hass.async_block_till_done() - assert hass.states.get(named_entity_id) - state = hass.states.get(named_entity_id) - assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py index 0acaddf36fc..547d574b25a 100644 --- a/tests/components/fastdotcom/test_init.py +++ b/tests/components/fastdotcom/test_init.py @@ -64,24 +64,27 @@ async def test_delayed_speedtest_during_startup( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 - ), patch.object(hass, "state", CoreState.starting): + original_state = hass.state + hass.set_state(CoreState.starting) + with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + hass.set_state(original_state) 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 + # Assert state is Unknown as fast.com isn't starting until HA has started + assert state.state is STATE_UNKNOWN - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + 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 state.state == "5.0" assert config_entry.state == config_entries.ConfigEntryState.LOADED diff --git a/tests/components/fastdotcom/test_service.py b/tests/components/fastdotcom/test_service.py new file mode 100644 index 00000000000..2f919bc8a84 --- /dev/null +++ b/tests/components/fastdotcom/test_service.py @@ -0,0 +1,87 @@ +"""Test Fastdotcom service.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN, SERVICE_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def test_service(hass: HomeAssistant) -> None: + """Test the Fastdotcom 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=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 == "0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + +async def test_service_unloaded_entry(hass: HomeAssistant) -> None: + """Test service called when config entry unloaded.""" + 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=0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry + await config_entry.async_unload(hass) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + assert "Fast.com is not loaded" in str(exc) + + +async def test_service_removed_entry(hass: HomeAssistant) -> None: + """Test service called when config entry was removed and HA was not restarted yet.""" + 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=0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry + await hass.config_entries.async_remove(config_entry.entry_id) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + assert "No Fast.com config entries found" in str(exc) diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 0c6ce300d01..9a88ef242e8 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -54,7 +54,7 @@ class MockFFmpegDev(ffmpeg.FFmpegBase): def __init__(self, hass, initial_state=True, entity_id="test.ffmpeg_device"): """Initialize mock.""" - super().__init__(initial_state) + super().__init__(None, initial_state) self.hass = hass self.entity_id = entity_id diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index cccea731a2c..81ae54174ca 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -40,7 +40,7 @@ class FidoClientMockError(FidoClientMock): raise PyFidoError("Fake Error") -async def test_fido_sensor(event_loop, hass: HomeAssistant) -> None: +async def test_fido_sensor(hass: HomeAssistant) -> None: """Test the Fido number sensor.""" with patch("homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock): config = { diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 1f93875a001..ffb306a23c1 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -276,14 +276,14 @@ async def test_setup( "icon": "mdi:test", ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test") assert state.attributes["icon"] == "mdi:test" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT assert state.state == "1.0" entity_id = entity_registry.async_get_entity_id( diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 91aafd944b0..59405e3ea91 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -6,6 +6,7 @@ from http import HTTPStatus from typing import Any import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion @@ -599,10 +600,11 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes", "server_status"), + ("scopes", "request_condition"), [ - (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), - (["heartrate"], HTTPStatus.BAD_REQUEST), + (["heartrate"], {"status_code": HTTPStatus.INTERNAL_SERVER_ERROR}), + (["heartrate"], {"status_code": HTTPStatus.BAD_REQUEST}), + (["heartrate"], {"exc": RequestsConnectionError}), ], ) async def test_sensor_update_failed( @@ -610,14 +612,14 @@ async def test_sensor_update_failed( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, - server_status: HTTPStatus, + request_condition: dict[str, Any], ) -> 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=server_status, + **request_condition, ) assert await integration_setup() diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py index 4cae6e4f4bf..e934f3c7e5f 100644 --- a/tests/components/flexit_bacnet/__init__.py +++ b/tests/components/flexit_bacnet/__init__.py @@ -1 +1,17 @@ """Tests for the Flexit Nordic (BACnet) integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Flexit Nordic (BACnet) integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.flexit_bacnet.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index b136b134e01..c192489805f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -1,13 +1,18 @@ """Configuration for Flexit Nordic (BACnet) tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from flexit_bacnet import FlexitBACnet import pytest from homeassistant import config_entries 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 + @pytest.fixture async def flow_id(hass: HomeAssistant) -> str: @@ -22,23 +27,61 @@ async def flow_id(hass: HomeAssistant) -> str: return result["flow_id"] -@pytest.fixture(autouse=True) -def mock_serial_number_and_device_name(): - """Mock serial number of the device.""" +@pytest.fixture +def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: + """Mock data from the device.""" + flexit_bacnet = AsyncMock(spec=FlexitBACnet) with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", - "0000-0001", + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet", + return_value=flexit_bacnet, ), patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", - "Device Name", + "homeassistant.components.flexit_bacnet.coordinator.FlexitBACnet", + return_value=flexit_bacnet, ): - yield + flexit_bacnet.serial_number = "0000-0001" + flexit_bacnet.device_name = "Device Name" + flexit_bacnet.room_temperature = 19.0 + flexit_bacnet.air_temp_setpoint_away = 18.0 + flexit_bacnet.air_temp_setpoint_home = 22.0 + flexit_bacnet.ventilation_mode = 4 + flexit_bacnet.air_filter_operating_time = 8000 + flexit_bacnet.outside_air_temperature = -8.6 + flexit_bacnet.supply_air_temperature = 19.1 + flexit_bacnet.exhaust_air_temperature = -3.3 + flexit_bacnet.extract_air_temperature = 19.0 + flexit_bacnet.fireplace_ventilation_remaining_duration = 10.0 + flexit_bacnet.rapid_ventilation_remaining_duration = 30.0 + flexit_bacnet.supply_air_fan_control_signal = 74 + flexit_bacnet.supply_air_fan_rpm = 2784 + flexit_bacnet.exhaust_air_fan_control_signal = 70 + flexit_bacnet.exhaust_air_fan_rpm = 2606 + flexit_bacnet.electric_heater_power = 0.39636585116386414 + flexit_bacnet.air_filter_operating_time = 8820.0 + flexit_bacnet.heat_exchanger_efficiency = 81 + flexit_bacnet.heat_exchanger_speed = 100 + flexit_bacnet.air_filter_polluted = False + flexit_bacnet.electric_heater = True + + yield flexit_bacnet @pytest.fixture -def mock_setup_entry(): +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """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 + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..bf53de3569c --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.device_name_air_filter_polluted-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.device_name_air_filter_polluted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air filter polluted', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_filter_polluted', + 'unique_id': '0000-0001-air_filter_polluted', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.device_name_air_filter_polluted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Device Name Air filter polluted', + }), + 'context': , + 'entity_id': 'binary_sensor.device_name_air_filter_polluted', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f363c99f8f2 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Device Name', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_mode': 'boost', + 'preset_modes': list([ + 'away', + 'home', + 'boost', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.device_name', + 'last_changed': , + 'last_updated': , + 'state': 'fan_only', + }) +# --- +# name: test_climate_entity.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_modes': list([ + 'away', + 'home', + 'boost', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.device_name', + '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': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0000-0001', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c1f8ad73eb1 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -0,0 +1,708 @@ +# serializer version: 1 +# name: test_sensors[sensor.device_name_air_filter_operating_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_air_filter_operating_time', + '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': None, + 'original_name': 'Air filter operating time', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_filter_operating_time', + 'unique_id': '0000-0001-air_filter_operating_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_air_filter_operating_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Air filter operating time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_air_filter_operating_time', + 'last_changed': , + 'last_updated': , + 'state': '8820.0', + }) +# --- +# name: test_sensors[sensor.device_name_electric_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_electric_heater_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': 'Electric heater power', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater_power', + 'unique_id': '0000-0001-electric_heater_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_electric_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Name Electric heater power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_electric_heater_power', + 'last_changed': , + 'last_updated': , + 'state': '0.396365851163864', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_name_exhaust_air_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust air fan', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_fan_rpm', + 'unique_id': '0000-0001-exhaust_air_fan_rpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Exhaust air fan', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_fan', + 'last_changed': , + 'last_updated': , + 'state': '2606', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan_control_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_name_exhaust_air_fan_control_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust air fan control signal', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_fan_control_signal', + 'unique_id': '0000-0001-exhaust_air_fan_control_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan_control_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Exhaust air fan control signal', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_fan_control_signal', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_exhaust_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_temperature', + 'unique_id': '0000-0001-exhaust_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Exhaust air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '-3.3', + }) +# --- +# name: test_sensors[sensor.device_name_extract_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_extract_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'extract_air_temperature', + 'unique_id': '0000-0001-extract_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_extract_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Extract air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_extract_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.0', + }) +# --- +# name: test_sensors[sensor.device_name_fireplace_ventilation_remaining_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_fireplace_ventilation_remaining_duration', + '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': 'Fireplace ventilation remaining duration', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_ventilation_remaining_duration', + 'unique_id': '0000-0001-fireplace_ventilation_remaining_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_fireplace_ventilation_remaining_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Fireplace ventilation remaining duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_fireplace_ventilation_remaining_duration', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_efficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_name_heat_exchanger_efficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heat exchanger efficiency', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_exchanger_efficiency', + 'unique_id': '0000-0001-heat_exchanger_efficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_efficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Heat exchanger efficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_heat_exchanger_efficiency', + 'last_changed': , + 'last_updated': , + 'state': '81', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_heat_exchanger_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heat exchanger speed', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_exchanger_speed', + 'unique_id': '0000-0001-heat_exchanger_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Heat exchanger speed', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_heat_exchanger_speed', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.device_name_outside_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_outside_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_air_temperature', + 'unique_id': '0000-0001-outside_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_outside_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Outside air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_outside_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '-8.6', + }) +# --- +# name: test_sensors[sensor.device_name_rapid_ventilation_remaining_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_rapid_ventilation_remaining_duration', + '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': 'Rapid ventilation remaining duration', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rapid_ventilation_remaining_duration', + 'unique_id': '0000-0001-rapid_ventilation_remaining_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_rapid_ventilation_remaining_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Rapid ventilation remaining duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_rapid_ventilation_remaining_duration', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[sensor.device_name_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'room_temperature', + 'unique_id': '0000-0001-room_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Room temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_room_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.0', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_name_supply_air_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supply air fan', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_fan_rpm', + 'unique_id': '0000-0001-supply_air_fan_rpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Supply air fan', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_fan', + 'last_changed': , + 'last_updated': , + 'state': '2784', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan_control_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_name_supply_air_fan_control_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supply air fan control signal', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_fan_control_signal', + 'unique_id': '0000-0001-supply_air_fan_control_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan_control_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Supply air fan control signal', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_fan_control_signal', + 'last_changed': , + 'last_updated': , + 'state': '74', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_supply_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_temperature', + 'unique_id': '0000-0001-supply_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Supply air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.1', + }) +# --- diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr new file mode 100644 index 00000000000..4db770917b0 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_switches[switch.device_name_electric_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_electric_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Electric heater', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater', + 'unique_id': '0000-0001-electric_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches_implementation[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/flexit_bacnet/test_binary_sensor.py b/tests/components/flexit_bacnet/test_binary_sensor.py new file mode 100644 index 00000000000..df363086f63 --- /dev/null +++ b/tests/components/flexit_bacnet/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the Flexit Nordic (BACnet) binary sensor entities.""" +from unittest.mock import AsyncMock + +from syrupy.assertion 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.flexit_bacnet import setup_with_selected_platforms + + +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states are correctly collected from library.""" + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + 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/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py new file mode 100644 index 00000000000..077aee019e7 --- /dev/null +++ b/tests/components/flexit_bacnet/test_climate.py @@ -0,0 +1,27 @@ +"""Tests for the Flexit Nordic (BACnet) climate entity.""" +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + +ENTITY_CLIMATE = "climate.device_name" + + +async def test_climate_entity( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert hass.states.get(ENTITY_CLIMATE) == snapshot + assert entity_registry.async_get(ENTITY_CLIMATE) == snapshot diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py index ed513587af6..860d25e4b75 100644 --- a/tests/components/flexit_bacnet/test_config_flow.py +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -1,31 +1,26 @@ """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: +async def test_form( + hass: HomeAssistant, flow_id: str, mock_setup_entry, mock_flexit_bacnet +) -> 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() + 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" @@ -35,6 +30,7 @@ async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None CONF_DEVICE_ID: 2, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_flexit_bacnet.mock_calls) == 1 @pytest.mark.parametrize( @@ -50,39 +46,39 @@ async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None ], ) async def test_flow_fails( - hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry + hass: HomeAssistant, + flow_id: str, + error: Exception, + message: str, + mock_setup_entry, + mock_flexit_bacnet, ) -> 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, - }, - ) + mock_flexit_bacnet.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, - }, - ) + mock_flexit_bacnet.update.side_effect = None + 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" @@ -94,27 +90,19 @@ async def test_flow_fails( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: +async def test_form_device_already_exist( + hass: HomeAssistant, flow_id: str, mock_flexit_bacnet, mock_config_entry +) -> None: """Test that we cannot add already added device.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + flow_id, + { 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() + await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/flexit_bacnet/test_init.py b/tests/components/flexit_bacnet/test_init.py new file mode 100644 index 00000000000..71f79f54302 --- /dev/null +++ b/tests/components/flexit_bacnet/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the Flexit Nordic (BACnet) __init__.""" +from flexit_bacnet import DecodingError + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_flexit_bacnet +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_failed_initialization( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_flexit_bacnet +) -> None: + """Test failed initialization.""" + mock_flexit_bacnet.update.side_effect = DecodingError + 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 == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/flexit_bacnet/test_sensor.py b/tests/components/flexit_bacnet/test_sensor.py new file mode 100644 index 00000000000..2285b4c8692 --- /dev/null +++ b/tests/components/flexit_bacnet/test_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the Flexit Nordic (BACnet) sensor entities.""" +from unittest.mock import AsyncMock + +from syrupy.assertion 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.flexit_bacnet import setup_with_selected_platforms + + +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor states are correctly collected from library.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + 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/flexit_bacnet/test_switch.py b/tests/components/flexit_bacnet/test_switch.py new file mode 100644 index 00000000000..7c08fc2a024 --- /dev/null +++ b/tests/components/flexit_bacnet/test_switch.py @@ -0,0 +1,135 @@ +"""Tests for the Flexit Nordic (BACnet) switch entities.""" +from unittest.mock import AsyncMock + +from flexit_bacnet import DecodingError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + +ENTITY_ID = "switch.device_name_electric_heater" + + +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch states are correctly collected from library.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + 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 (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_switches_implementation( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the switch can be turned on and off.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") + + # Set to off + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Set to on + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Error recovery, when turning off + mock_flexit_bacnet.disable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.disable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Error recovery, when turning on + mock_flexit_bacnet.enable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.enable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 5d619f9e91f..eb7785dbd61 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -90,7 +90,11 @@ async def test_device( # test error sending device ping with patch( - "homeassistant.components.flo.device.FloDeviceDataUpdateCoordinator.send_presence_ping", + "aioflo.presence.Presence.ping", side_effect=RequestError, ), pytest.raises(UpdateFailed): + # simulate 4 updates failing + await valve._async_update_data() + await valve._async_update_data() + await valve._async_update_data() await valve._async_update_data() diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index 9d6f95b2559..6a90bbd9ba8 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -11,7 +11,7 @@ from freebox_api.exceptions import ( from homeassistant import data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -58,17 +58,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["step_id"] == "link" -async def test_import(hass: HomeAssistant) -> None: - """Test import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - async def test_zeroconf(hass: HomeAssistant) -> None: """Test zeroconf step.""" result = await hass.config_entries.flow.async_init( @@ -83,8 +72,6 @@ async def test_zeroconf(hass: HomeAssistant) -> None: async def test_link(hass: HomeAssistant, router: Mock) -> None: """Test linking.""" with patch( - "homeassistant.components.freebox.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.freebox.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -101,7 +88,6 @@ async def test_link(hass: HomeAssistant, router: Mock) -> None: assert result["data"][CONF_HOST] == MOCK_HOST assert result["data"][CONF_PORT] == MOCK_PORT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -113,15 +99,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: unique_id=MOCK_HOST, ).add_to_hass(hass) - # Should fail, same MOCK_HOST (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - # Should fail, same MOCK_HOST (flow) result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index dc27e8aab96..d39cb21beea 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -132,6 +132,21 @@ MOCK_FB_SERVICES: dict[str, dict] = { }, "GetPortMappingNumberOfEntries": {}, }, + "WLANConfiguration1": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewSSID": "MyWifi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + }, + "GetSSID": { + "NewSSID": "MyWifi", + }, + "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, + }, "X_AVM-DE_Homeauto1": { "GetGenericDeviceInfos": [ { diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index ded7cda0dea..5b87d897dd9 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -9,11 +9,13 @@ from fritzconnection.core.exceptions import ( ) import pytest +from homeassistant import data_entry_flow from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) from homeassistant.components.fritz.const import ( + CONF_OLD_DISCOVERY, DOMAIN, ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, @@ -453,28 +455,24 @@ async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip) -> None: assert result["step_id"] == "confirm" -async def test_options_flow( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip -) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) mock_config.add_to_hass(hass) - with patch( - "homeassistant.components.fritz.config_flow.FritzConnection", - side_effect=fc_class_mock, - ), patch("homeassistant.components.fritz.common.FritzBoxTools"): - result = await hass.config_entries.options.async_init(mock_config.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_config.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_config.entry_id) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 37, - }, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert mock_config.options[CONF_CONSIDER_HOME] == 37 + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_OLD_DISCOVERY: False, + CONF_CONSIDER_HOME: 37, + } diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index 760b5f32d0c..9751e25de72 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -62,6 +62,7 @@ async def test_entry_diagnostics( "WANDSLInterfaceConfig1", "WANIPConn1", "WANPPPConnection1", + "WLANConfiguration1", "X_AVM-DE_Homeauto1", "X_AVM-DE_HostFilter1", ], diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index e3f0d7f35d5..274d916f10d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -23,7 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockUser, async_capture_events, async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator MOCK_THEMES = { "happy": {"primary-color": "red", "app-header-background-color": "blue"}, @@ -664,3 +664,76 @@ async def test_static_path_cache(hass: HomeAssistant, mock_http_client) -> None: # and again to make sure the cache works resp = await mock_http_client.get("/static/does-not-exist", allow_redirects=False) assert resp.status == 404 + + +async def test_get_icons(hass: HomeAssistant, ws_client: MockHAClientWebSocket) -> None: + """Test get_icons command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: {}, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "category": "entity_component", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {}} + + +async def test_get_icons_for_integrations( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integrations command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": ["frontend", "http"], + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert set(msg["result"]["resources"]) == {"frontend", "http"} + + +async def test_get_icons_for_single_integration( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integration command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": "http", + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {"http": {}}} diff --git a/tests/components/gdacs/conftest.py b/tests/components/gdacs/conftest.py index 47185cf5387..ee82a3131b1 100644 --- a/tests/components/gdacs/conftest.py +++ b/tests/components/gdacs/conftest.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def config_entry(): +def config_entry() -> MockConfigEntry: """Create a mock GDACS config entry.""" return MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index f8dfa0cd7fd..ad673815ace 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -1,5 +1,4 @@ """Define tests for the GDACS config flow.""" -from datetime import timedelta from unittest.mock import patch import pytest @@ -12,8 +11,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.core import HomeAssistant @pytest.fixture(name="gdacs_setup", autouse=True) @@ -44,56 +42,6 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_step_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - conf = { - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_SCAN_INTERVAL: timedelta(minutes=4), - CONF_CATEGORIES: ["Drought", "Earthquake"], - } - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "-41.2, 174.7" - assert result["data"] == { - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_SCAN_INTERVAL: 240.0, - CONF_CATEGORIES: ["Drought", "Earthquake"], - } - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_step_import_already_exist( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" hass.config.latitude = -41.2 diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index dfdce7635df..9fd8c5c0134 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -4,7 +4,6 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED from homeassistant.components.gdacs.geo_location import ( ATTR_ALERT_LEVEL, @@ -33,18 +32,21 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed -CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} +CONFIG = {CONF_RADIUS: 200} -async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -94,8 +96,12 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> "aio_georss_client.feed.GeoRssFeed.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, gdacs.DOMAIN, CONFIG) - await hass.async_block_till_done() + + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | CONFIG + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() @@ -202,7 +208,9 @@ 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, config_entry: MockConfigEntry +) -> 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. @@ -224,9 +232,13 @@ async def test_setup_imperial(hass: HomeAssistant) -> None: "aio_georss_client.feed.GeoRssFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] - assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG) - await hass.async_block_till_done() - # Artificially trigger update and collect events. + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | CONFIG + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup( + config_entry.entry_id + ) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 670d3efce51..9e585de41dd 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -5,6 +5,7 @@ from freezegun import freeze_time from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL +from homeassistant.components.gdacs.const import CONF_CATEGORIES from homeassistant.components.gdacs.sensor import ( ATTR_CREATED, ATTR_LAST_UPDATE, @@ -16,18 +17,18 @@ from homeassistant.components.gdacs.sensor import ( from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import _generate_mock_feed_entry -from tests.common import async_fire_time_changed - -CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup(hass: HomeAssistant) -> None: @@ -60,7 +61,24 @@ async def test_setup(hass: HomeAssistant) -> None: "aio_georss_client.feed.GeoRssFeed.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, gdacs.DOMAIN, CONFIG) + latitude = 32.87336 + longitude = -117.22743 + radius = 200 + entry_data = { + CONF_RADIUS: radius, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_CATEGORIES: [], + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL.seconds, + } + config_entry = MockConfigEntry( + domain=gdacs.DOMAIN, + title=f"{latitude}, {longitude}", + data=entry_data, + unique_id="my_very_unique_id", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 70746f70c9a..9fe394d05c3 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -11,6 +12,7 @@ import pytest import respx from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, async_get_mjpeg_stream, async_get_stream_source, ) @@ -24,8 +26,13 @@ from homeassistant.components.generic.const import ( ) from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -33,29 +40,56 @@ from tests.common import Mock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +async def help_setup_mock_config_entry( + hass: HomeAssistant, options: dict[str, Any], unique_id: Any | None = None +) -> MockConfigEntry: + """Help setting up a generic camera config entry.""" + entry_options = { + CONF_STILL_IMAGE_URL: options.get(CONF_STILL_IMAGE_URL), + CONF_STREAM_SOURCE: options.get(CONF_STREAM_SOURCE), + CONF_AUTHENTICATION: options.get(CONF_AUTHENTICATION), + CONF_USERNAME: options.get(CONF_USERNAME), + CONF_PASSWORD: options.get(CONF_PASSWORD), + CONF_LIMIT_REFETCH_TO_URL_CHANGE: options.get( + CONF_LIMIT_REFETCH_TO_URL_CHANGE, False + ), + CONF_CONTENT_TYPE: options.get(CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE), + CONF_FRAMERATE: options.get(CONF_FRAMERATE, 2), + CONF_VERIFY_SSL: options.get(CONF_VERIFY_SSL), + } + entry = MockConfigEntry( + domain="generic", + title=options[CONF_NAME], + options=entry_options, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + return entry + + @respx.mock async def test_fetching_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + fakeimgbytes_png, + caplog: pytest.CaptureFixture, ) -> None: """Test that it fetches the given url.""" - respx.get("http://example.com").respond(stream=fakeimgbytes_png) + hass.states.async_set("sensor.temp", "http://example.com/0a") + respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png) + respx.get("http://example.com/1a").respond(stream=fakeimgbytes_png) - 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": 20, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "{{ states.sensor.temp.state }}", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": 20, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -72,6 +106,25 @@ async def test_fetching_url( resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 + # If the template renders to an invalid URL we return the last image from cache + hass.states.async_set("sensor.temp", "invalid url") + + # sleep another .1 seconds to make cached image expire + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + assert respx.calls.call_count == 2 + assert ( + "Invalid URL 'invalid url': expected a URL, returning last image" in caplog.text + ) + + # Restore a valid URL + hass.states.async_set("sensor.temp", "http://example.com/1a") + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + assert respx.calls.call_count == 3 + @respx.mock async def test_image_caching( @@ -84,22 +137,16 @@ async def test_image_caching( 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() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -154,21 +201,15 @@ async def test_fetching_without_verify_ssl( """Test that it fetches the given url when ssl verify is off.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "username": "user", - "password": "pass", - "verify_ssl": "false", - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "username": "user", + "password": "pass", + "verify_ssl": "false", + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -184,21 +225,15 @@ async def test_fetching_url_with_verify_ssl( """Test that it fetches the given url when ssl verify is explicitly on.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "username": "user", - "password": "pass", - "verify_ssl": "true", - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "username": "user", + "password": "pass", + "verify_ssl": True, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -223,19 +258,13 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "0") - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -350,20 +379,15 @@ async def test_stream_source_error( """Test that the stream source has an error.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - # Does not exist - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, - }, - ) + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + # Does not exist + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) assert await async_setup_component(hass, "stream", {}) await hass.async_block_till_done() @@ -397,23 +421,17 @@ async def test_setup_alternative_options( """Test that the stream source is setup with different config options.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "authentication": "digest", - "username": "user", - "password": "pass", - "stream_source": "rtsp://example.com:554/rtsp/", - "rtsp_transport": "udp", - }, - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "authentication": "digest", + "username": "user", + "password": "pass", + "stream_source": "rtsp://example.com:554/rtsp/", + "rtsp_transport": "udp", + } + await help_setup_mock_config_entry(hass, options) assert hass.states.get("camera.config_test") @@ -427,19 +445,13 @@ async def test_no_stream_source( """Test a stream request without stream source option set.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "limit_refetch_to_url_change": True, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) with patch( "homeassistant.components.camera.Stream.endpoint_url", @@ -494,22 +506,9 @@ async def test_camera_content_type( "framerate": 2, "verify_ssl": True, } + await help_setup_mock_config_entry(hass, cam_config_jpg, unique_id=12345) + await help_setup_mock_config_entry(hass, cam_config_svg, unique_id=54321) - result1 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_jpg, - context={"source": SOURCE_IMPORT, "unique_id": 12345}, - ) - await hass.async_block_till_done() - result2 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_svg, - context={"source": SOURCE_IMPORT, "unique_id": 54321}, - ) - await hass.async_block_till_done() - - assert result1["type"] == "create_entry" - assert result2["type"] == "create_entry" client = await hass_client() resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg") @@ -538,21 +537,15 @@ async def test_timeout_cancelled( respx.get("http://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "framerate": 20, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "framerate": 20, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -589,19 +582,13 @@ async def test_timeout_cancelled( async def test_frame_interval_property(hass: HomeAssistant) -> None: """Test that the frame interval is calculated and returned correctly.""" - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - "framerate": 5, - }, - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + "framerate": 5, + } + await help_setup_mock_config_entry(hass, options) request = Mock() with patch( diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index c4d11d4af22..86bd552bcf3 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -34,9 +34,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -756,35 +756,6 @@ async def test_options_only_stream( assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" -# These below can be deleted after deprecation period is finished. -@respx.mock -async def test_import(hass: HomeAssistant, fakeimg_png) -> None: - """Test configuration.yaml import used during migration.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - # duplicate import should be aborted - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Yaml Defined Name" - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_generic" - ) - assert issue.translation_key == "deprecated_yaml" - - # Any name defined in yaml should end up as the entity id. - assert hass.states.get("camera.yaml_defined_name") - assert result2["type"] == FlowResultType.ABORT - - -# These above can be deleted after deprecation period is finished. - - async def test_unload_entry(hass: HomeAssistant, fakeimg_png) -> None: """Test unloading the generic IP Camera entry.""" mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 9c0fa7ddaef..d68e5ca78e0 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -1376,7 +1376,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1414,7 +1414,7 @@ async def test_restore_state_target_humidity(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1457,7 +1457,7 @@ async def test_restore_state_and_return_to_normal(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1512,7 +1512,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 9196de8b096..e0b6e5c9987 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1317,7 +1317,7 @@ async def test_restore_state(hass: HomeAssistant, hvac_mode) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1355,7 +1355,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1432,7 +1432,7 @@ async def test_restore_will_turn_off_(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} @@ -1480,7 +1480,7 @@ async def test_restore_will_turn_off_when_loaded_second(hass: HomeAssistant) -> ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() assert hass.states.get(heater_switch) is None diff --git a/tests/components/geo_json_events/__init__.py b/tests/components/geo_json_events/__init__.py index f95ee747bf3..7d7148b3c20 100644 --- a/tests/components/geo_json_events/__init__.py +++ b/tests/components/geo_json_events/__init__.py @@ -1,4 +1,5 @@ """Tests for the geo_json_events component.""" +from typing import Any from unittest.mock import MagicMock @@ -7,6 +8,7 @@ def _generate_mock_feed_entry( title: str, distance_to_home: float, coordinates: tuple[float, float], + properties: dict[str, Any] | None = None, ) -> MagicMock: """Construct a mock feed entry for testing purposes.""" feed_entry = MagicMock() @@ -14,4 +16,5 @@ def _generate_mock_feed_entry( feed_entry.title = title feed_entry.distance_to_home = distance_to_home feed_entry.coordinates = coordinates + feed_entry.properties = properties return feed_entry diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 3875a525e73..2f3b12ed554 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_RADIUS, CONF_SCAN_INTERVAL, @@ -50,8 +51,16 @@ async def test_entity_lifecycle( """Test entity lifecycle..""" config_entry.add_to_hass(hass) # Set up a mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) + mock_entry_1 = _generate_mock_feed_entry( + "1234", + "Title 1", + 15.5, + (-31.0, 150.0), + {ATTR_NAME: "Properties 1"}, + ) + mock_entry_2 = _generate_mock_feed_entry( + "2345", "271310188", 20.5, (-31.1, 150.1), {ATTR_NAME: 271310188} + ) 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)) @@ -69,27 +78,27 @@ async def test_entity_lifecycle( assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 assert len(entity_registry.entities) == 3 - state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_1") + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.properties_1") assert state is not None - assert state.name == "Title 1" + assert state.name == "Properties 1" assert state.attributes == { ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, - ATTR_FRIENDLY_NAME: "Title 1", + ATTR_FRIENDLY_NAME: "Properties 1", ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 15.5), 7) == 0 - state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_2") + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.271310188") assert state is not None - assert state.name == "Title 2" + assert state.name == "271310188" assert state.attributes == { ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, - ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FRIENDLY_NAME: "271310188", ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index 02225df3755..c86ef393875 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/geofency/test_init.py b/tests/components/geofency/test_init.py index d5ababaee41..2ab2d9cc8bb 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -116,7 +116,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def geofency_client(event_loop, hass, hass_client_no_auth): +async def geofency_client(hass, hass_client_no_auth): """Geofency mock client (unauthenticated).""" assert await async_setup_component( @@ -129,7 +129,7 @@ async def geofency_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index 8d61eca1ab1..32388fb65d1 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -121,10 +121,11 @@ async def test_flow_with_activation_failure( ) assert result["step_id"] == "device" assert result["type"] == FlowResultType.SHOW_PROGRESS + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "could_not_register" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "could_not_register" async def test_flow_with_remove_while_activating( diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 91f8da92799..f0f1fe01796 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -6,7 +6,6 @@ MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", - "version": 3, "port": 61208, "ssl": False, "verify_ssl": True, diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d08064e8647 --- /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': 'container_active', + '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_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_containers_cpu_usage', + '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 usage', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'container_cpu_usage', + 'unique_id': 'test--docker_cpu_use', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Containers CPU usage', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_cpu_usage', + 'last_changed': , + 'last_updated': , + 'state': '77.2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_memory_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_memory_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 memory used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'container_memory_used', + 'unique_id': 'test--docker_memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_memory_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Containers memory used', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_memory_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': 'temperature', + '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_data_size-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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_data_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'Data size', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_used', + 'unique_id': 'test--memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_data_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Data size', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_data_size', + 'last_changed': , + 'last_updated': , + 'state': '1047.1', + }) +# --- +# 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': 'temperature', + '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_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_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 available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raid_available', + 'unique_id': 'test-md1-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_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_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 used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raid_used', + 'unique_id': 'test-md1-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_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_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 available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raid_available', + 'unique_id': 'test-md3-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_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_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 used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raid_used', + 'unique_id': 'test-md3-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_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_disk_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 disk free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_free', + 'unique_id': 'test-/media-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media disk free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_disk_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_media_disk_usage', + '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 disk usage', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'test-/media-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /media disk usage', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_disk_usage', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_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_disk_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 disk used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': 'test-/media-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media disk used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_disk_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_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_memory_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'Memory free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_free', + 'unique_id': 'test--memory_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Memory free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_memory_free', + 'last_changed': , + 'last_updated': , + 'state': '2745.0', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:memory', + 'original_name': 'Memory usage', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_usage', + 'unique_id': 'test--memory_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Memory usage', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_memory_usage', + 'last_changed': , + 'last_updated': , + 'state': '27.6', + }) +# --- +# 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': 'temperature', + '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_ssl_disk_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_disk_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 disk free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_free', + 'unique_id': 'test-/ssl-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl disk free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_disk_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ssl_disk_usage', + '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 disk usage', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'test-/ssl-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /ssl disk usage', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_disk_usage', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_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_disk_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 disk used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': 'test-/ssl-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl disk used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_disk_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 87ec80da057..8d590317c61 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock 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 61cbc610060..764426c6276 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 af00126b219..aeef1de0b09 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,102 +1,29 @@ """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) - assert hass.states.get("sensor.0_0_0_0_ssl_used").state == str( - HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - ) - assert hass.states.get("sensor.0_0_0_0_cpu_thermal_1_temperature").state == str( - HA_SENSOR_DATA["sensors"]["cpu_thermal 1"]["temperature_core"] - ) - assert hass.states.get("sensor.0_0_0_0_err_temp_temperature").state == str( - HA_SENSOR_DATA["sensors"]["err_temp"]["temperature_hdd"] - ) - assert hass.states.get("sensor.0_0_0_0_na_temp_temperature").state == str( - HA_SENSOR_DATA["sensors"]["na_temp"]["temperature_hdd"] - ) - assert hass.states.get("sensor.0_0_0_0_ram_used_percent").state == str( - HA_SENSOR_DATA["mem"]["memory_use_percent"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_active").state == str( - HA_SENSOR_DATA["docker"]["docker_active"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_cpu_used").state == str( - HA_SENSOR_DATA["docker"]["docker_cpu_use"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_ram_used").state == str( - HA_SENSOR_DATA["docker"]["docker_memory_use"] - ) - assert hass.states.get("sensor.0_0_0_0_md3_raid_available").state == str( - HA_SENSOR_DATA["raid"]["md3"]["available"] - ) - assert hass.states.get("sensor.0_0_0_0_md3_raid_used").state == str( - HA_SENSOR_DATA["raid"]["md3"]["used"] - ) - assert hass.states.get("sensor.0_0_0_0_md1_raid_available").state == str( - HA_SENSOR_DATA["raid"]["md1"]["available"] - ) - assert hass.states.get("sensor.0_0_0_0_md1_raid_used").state == str( - HA_SENSOR_DATA["raid"]["md1"]["used"] - ) - - -@pytest.mark.parametrize( - ("object_id", "old_unique_id", "new_unique_id"), - [ - ( - "glances_ssl_used_percent", - "0.0.0.0-Glances /ssl used percent", - "/ssl-disk_use_percent", - ), - ( - "glances_cpu_thermal_1_temperature", - "0.0.0.0-Glances cpu_thermal 1 Temperature", - "cpu_thermal 1-temperature_core", - ), - ], -) -async def test_migrate_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - object_id: str, - old_unique_id: str, - new_unique_id: str, -) -> None: - """Test unique id migration.""" - old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} - entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) - entry.add_to_hass(hass) - - entity: er.RegistryEntry = entity_registry.async_get_or_create( - suggested_object_id=object_id, - disabled_by=None, - domain=SENSOR_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_migrated = entity_registry.async_get(entity.entity_id) - assert entity_migrated - assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_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/google/test_calendar.py b/tests/components/google/test_calendar.py index d1cc41e166a..55a9f814a63 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -571,7 +571,6 @@ async def test_scan_calendar_error( config_entry, ) -> None: """Test that the calendar update handles a server error.""" - config_entry.add_to_hass(hass) mock_calendars_list({}, exc=ClientError()) assert await component_setup() diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index b2c472757b6..f8eff022d9f 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable import datetime from http import HTTPStatus @@ -10,6 +11,7 @@ from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from oauth2client.client import ( DeviceFlowInfo, FlowExchangeError, @@ -273,6 +275,7 @@ async def test_exchange_error( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, + freezer: FrozenDateTimeFactory, ) -> None: """Test an error while exchanging the code for credentials.""" await async_import_client_credential( @@ -290,14 +293,19 @@ async def test_exchange_error( assert "url" in result["description_placeholders"] # Run one tick to invoke the credential exchange check - now = utcnow() + step2_exchange_called = asyncio.Event() + + def step2_exchange(*args, **kwargs): + hass.loop.call_soon_threadsafe(step2_exchange_called.set) + raise FlowExchangeError + with patch( "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", - side_effect=FlowExchangeError(), + side_effect=step2_exchange, ): - now += CODE_CHECK_ALARM_TIMEDELTA - await fire_alarm(hass, now) - await hass.async_block_till_done() + freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) + async_fire_time_changed(hass, utcnow()) + await step2_exchange_called.wait() # Status has not updated, will retry result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) @@ -308,8 +316,8 @@ async def test_exchange_error( with patch( "homeassistant.components.google.async_setup_entry", return_value=True ) as mock_setup: - now += CODE_CHECK_ALARM_TIMEDELTA - await fire_alarm(hass, now) + freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) + async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"] diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 6fc1c9f580d..931f4d25522 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -305,6 +305,7 @@ DEMO_DEVICES = [ "id": "climate.hvac", "name": {"name": "Hvac"}, "traits": [ + "action.devices.traits.OnOff", "action.devices.traits.TemperatureSetting", "action.devices.traits.FanSpeed", ], @@ -326,7 +327,10 @@ DEMO_DEVICES = [ { "id": "climate.heatpump", "name": {"name": "HeatPump"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.TemperatureSetting", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, }, @@ -334,6 +338,7 @@ DEMO_DEVICES = [ "id": "climate.ecobee", "name": {"name": "Ecobee"}, "traits": [ + "action.devices.traits.OnOff", "action.devices.traits.TemperatureSetting", "action.devices.traits.FanSpeed", ], diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 177220cc02f..4fb6f50a5e6 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -233,25 +233,28 @@ async def test_query_climate_request( assert len(devices) == 3 assert devices["climate.heatpump"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": 20.0, "thermostatTemperatureAmbient": 25.0, "thermostatMode": "heat", } assert devices["climate.ecobee"] == { "online": True, + "on": True, "thermostatTemperatureSetpointHigh": 24, "thermostatTemperatureAmbient": 23, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": 21, - "currentFanSpeedSetting": "Auto Low", + "currentFanSpeedSetting": "auto_low", } assert devices["climate.hvac"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": 21, "thermostatTemperatureAmbient": 22, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, - "currentFanSpeedSetting": "On High", + "currentFanSpeedSetting": "on_high", } @@ -294,25 +297,28 @@ async def test_query_climate_request_f( assert len(devices) == 3 assert devices["climate.heatpump"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": -6.7, "thermostatTemperatureAmbient": -3.9, "thermostatMode": "heat", } assert devices["climate.ecobee"] == { "online": True, + "on": True, "thermostatTemperatureSetpointHigh": -4.4, "thermostatTemperatureAmbient": -5, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": -6.1, - "currentFanSpeedSetting": "Auto Low", + "currentFanSpeedSetting": "auto_low", } assert devices["climate.hvac"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": -6.1, "thermostatTemperatureAmbient": -5.6, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, - "currentFanSpeedSetting": "On High", + "currentFanSpeedSetting": "on_high", } hass_fixture.config.units.temperature_unit = UnitOfTemperature.CELSIUS diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 4ec61b75171..29ac7c3b48d 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -1,7 +1,6 @@ """Test Google report state.""" from datetime import datetime, timedelta from http import HTTPStatus -from time import mktime from unittest.mock import AsyncMock, patch import pytest @@ -136,7 +135,7 @@ async def test_report_state( assert len(mock_report.mock_calls) == 0 -@pytest.mark.freeze_time("2023-08-01 00:00:00") +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") async def test_report_notifications( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -172,7 +171,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus(200) ) as mock_report_state: event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T00:02:57+00:00", @@ -211,7 +210,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus(500) ) as mock_report_state: event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T01:02:57+00:00", @@ -247,7 +246,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus.NOT_FOUND ) as mock_report_state, patch.object(config, "async_disconnect_agent_user"): event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T01:03:57+00:00", diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bf48564c251..9063a8977f6 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -971,7 +971,6 @@ async def test_device_class_switch( None, "Demo Sensor", state=False, - icon="mdi:switch", assumed=False, device_class=device_class, ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 3f1e28cb667..58cbc5dce0e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1080,7 +1080,9 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: "climate.bla", climate.HVACMode.AUTO, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [ climate.HVACMode.OFF, climate.HVACMode.COOL, @@ -1161,7 +1163,9 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: { climate.ATTR_CURRENT_TEMPERATURE: 70, climate.ATTR_CURRENT_HUMIDITY: 25, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [ STATE_OFF, climate.HVACMode.COOL, @@ -1273,7 +1277,9 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None "climate.bla", climate.HVACMode.COOL, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [STATE_OFF, climate.HVACMode.COOL], climate.ATTR_MIN_TEMP: 10, climate.ATTR_MAX_TEMP: 30, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 636a46e42f5..5347c010f28 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,33 +1,109 @@ # serializer version: 1 # name: test_default_prompt - dict({ - 'context': ''' - This smart home is controlled by Home Assistant. - - An overview of the areas and the devices in this smart home: - - Test Area: - - Test Device (Test Model) - - Test Area 2: - - Test Device 2 - - Test Device 3 (Test Model 3A) - - Test Device 4 - - 1 (3) - - Answer the user's questions about the world truthfully. - - If the user wants to control a device, reject the request and suggest using the Home Assistant app. - ''', - 'messages': list([ + list([ + tuple( + '', + tuple( + ), dict({ - 'author': '0', - 'content': 'hello', + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 0.9, + 'top_k': 1, + 'top_p': 1.0, + }), + 'model_name': 'models/gemini-pro', }), - ]), - 'model': 'models/chat-bison-001', - 'temperature': 0.25, - 'top_k': 40, - 'top_p': 0.95, - }) + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- +# name: test_generate_content_service_with_image + list([ + tuple( + '', + tuple( + ), + dict({ + 'model_name': 'gemini-pro-vision', + }), + ), + tuple( + '().generate_content_async', + tuple( + list([ + 'Describe this image from my doorbell camera', + dict({ + 'data': b'image bytes', + 'mime_type': 'image/jpeg', + }), + ]), + ), + dict({ + }), + ), + ]) +# --- +# name: test_generate_content_service_without_images + list([ + tuple( + '', + tuple( + ), + dict({ + 'model_name': 'gemini-pro', + }), + ), + tuple( + '().generate_content_async', + tuple( + list([ + 'Write an opening speech for a Home Assistant release party', + ]), + ), + dict({ + }), + ), + ]) # --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 0b7072f4ef0..4a2478c5a7a 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -8,9 +8,11 @@ import pytest from homeassistant import config_entries from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_TOP_K, DEFAULT_TOP_P, DOMAIN, @@ -37,7 +39,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", return_value=True, @@ -78,6 +80,7 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL assert options["data"][CONF_TOP_P] == DEFAULT_TOP_P assert options["data"][CONF_TOP_K] == DEFAULT_TOP_K + assert options["data"][CONF_MAX_TOKENS] == DEFAULT_MAX_TOKENS @pytest.mark.parametrize( @@ -104,7 +107,7 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: ) with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 982f3993e04..380d5e82638 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,11 +1,13 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from google.api_core.exceptions import ClientError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation 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 tests.common import MockConfigEntry @@ -91,20 +93,24 @@ async def test_default_prompt( model=3, suggested_area="Test Area 2", ) - with patch("google.generativeai.chat_async") as mock_chat: + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_model.return_value.start_chat.return_value = AsyncMock() result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_chat.mock_calls[0][2] == snapshot + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: """Test that the default prompt works.""" - with patch("google.generativeai.chat_async", side_effect=ClientError("")): + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + mock_chat.send_message_async.side_effect = ClientError("") result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) @@ -125,7 +131,7 @@ async def test_template_error( ) with patch( "google.generativeai.get_model", - ), patch("google.generativeai.chat_async"): + ), patch("google.generativeai.GenerativeModel"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -146,3 +152,168 @@ async def test_conversation_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" + + +async def test_generate_content_service_without_images( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + + "party for the latest version of Home Assistant!" + ) + + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_response = MagicMock() + mock_response.text = stubbed_generated_content + mock_model.return_value.generate_content_async = AsyncMock( + return_value=mock_response + ) + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + +async def test_generate_content_service_with_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with patch("google.generativeai.GenerativeModel") as mock_model, patch( + "homeassistant.components.google_generative_ai_conversation.Path.read_bytes", + return_value=b"image bytes", + ), patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ): + mock_response = MagicMock() + mock_response.text = stubbed_generated_content + mock_model.return_value.generate_content_async = AsyncMock( + return_value=mock_response + ) + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles errors.""" + with patch("google.generativeai.GenerativeModel") as mock_model, pytest.raises( + HomeAssistantError, match="Error generating content: None reason" + ): + mock_model.return_value.generate_content_async = AsyncMock( + side_effect=ClientError("reason") + ) + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_image_not_allowed_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with an image in a not allowed path.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=False + ), pytest.raises( + HomeAssistantError, + match="Cannot read `doorbell_snapshot.jpg`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_image_not_exists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with an image that does not exist.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ), patch("pathlib.Path.exists", return_value=False), pytest.raises( + HomeAssistantError, match="`doorbell_snapshot.jpg` does not exist" + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_non_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with a non image.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ), patch("pathlib.Path.exists", return_value=True), pytest.raises( + HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.mp4", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 9e575389e72..b701fcb2143 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -115,7 +115,7 @@ async def test_timeout(hass: HomeAssistant) -> None: ) assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": "timeout_connect"} async def test_malformed_api_key(hass: HomeAssistant) -> None: diff --git a/tests/components/govee_ble/__init__.py b/tests/components/govee_ble/__init__.py index 5dd67adb160..c093a6dddb5 100644 --- a/tests/components/govee_ble/__init__.py +++ b/tests/components/govee_ble/__init__.py @@ -74,3 +74,13 @@ GVH5178_SERVICE_INFO_ERROR = BluetoothServiceInfo( service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], source="local", ) + +GVH5106_SERVICE_INFO = BluetoothServiceInfo( + name="GVH5106_4E05", + address="CC:32:37:35:4E:05", + rssi=-66, + manufacturer_data={1: b"\x01\x01\x0e\xd12\x98"}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 5e7ca299fb6..55f3d293096 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from . import ( GVH5075_SERVICE_INFO, + GVH5106_SERVICE_INFO, GVH5178_PRIMARY_SERVICE_INFO, GVH5178_REMOTE_SERVICE_INFO, GVH5178_SERVICE_INFO_ERROR, @@ -153,3 +154,30 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: primary_temp_sensor = hass.states.get("sensor.b51782bc8_primary_temperature") assert primary_temp_sensor.state == STATE_UNAVAILABLE + + +async def test_gvh5106(hass: HomeAssistant) -> None: + """Test setting up creates the sensors for a device with PM25.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="CC:32:37:35:4E:05", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, GVH5106_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + pm25_sensor = hass.states.get("sensor.h5106_4e05_pm25") + pm25_sensor_attributes = pm25_sensor.attributes + assert pm25_sensor.state == "0" + assert pm25_sensor_attributes[ATTR_FRIENDLY_NAME] == "H5106 4E05 Pm25" + assert pm25_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "µg/m³" + assert pm25_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/govee_light_local/__init__.py b/tests/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..b4ea9560d25 --- /dev/null +++ b/tests/components/govee_light_local/__init__.py @@ -0,0 +1 @@ +"""Tests for the Govee Light local integration.""" diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py new file mode 100644 index 00000000000..2b3690f7011 --- /dev/null +++ b/tests/components/govee_light_local/conftest.py @@ -0,0 +1,37 @@ +"""Tests configuration for Govee Local API.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeLightCapability +import pytest + +from homeassistant.components.govee_light_local.coordinator import GoveeController + + +@pytest.fixture(name="mock_govee_api") +def fixture_mock_govee_api(): + """Set up Govee Local API fixture.""" + mock_api = AsyncMock(spec=GoveeController) + mock_api.start = AsyncMock() + mock_api.turn_on_off = AsyncMock() + mock_api.set_brightness = AsyncMock() + mock_api.set_color = AsyncMock() + mock_api._async_update_data = AsyncMock() + return mock_api + + +@pytest.fixture(name="mock_setup_entry") +def fixture_mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.govee_light_local.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { + GoveeLightCapability.COLOR_RGB, + GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, + GoveeLightCapability.BRIGHTNESS, +} diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py new file mode 100644 index 00000000000..7753b40c29c --- /dev/null +++ b/tests/components/govee_light_local/test_config_flow.py @@ -0,0 +1,74 @@ +"""Test Govee light local config flow.""" +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import DEFAULT_CAPABILITEIS + + +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_govee_api: AsyncMock +) -> None: + """Test setting up Govee with no devices.""" + + mock_govee_api.devices = [] + + with patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_govee_api, + ): + 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() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_not_called() + + +async def test_creating_entry_has_with_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_govee_api: AsyncMock, +) -> None: + """Test setting up Govee with devices.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_govee_api, + ): + 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() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py new file mode 100644 index 00000000000..1e211610d7a --- /dev/null +++ b/tests/components/govee_light_local/test_light.py @@ -0,0 +1,336 @@ +"""Test Govee light local.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import DEFAULT_CAPABILITEIS + +from tests.common import MockConfigEntry + + +async def test_light_known_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} + + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None + + +async def test_light_unknown_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.101", + fingerprint="unkown_device", + sku="XYZK", + capabilities=None, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.XYZK") + assert light is not None + + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + + +async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None + + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_light_setup_retry( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.devices = [] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.govee_light_local.asyncio.timeout", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + + +async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with( + mock_govee_api.devices[0], 100 + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with( + mock_govee_api.devices[0], 100 + ) + + +async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) diff --git a/tests/components/gpsd/__init__.py b/tests/components/gpsd/__init__.py new file mode 100644 index 00000000000..d78331c94d9 --- /dev/null +++ b/tests/components/gpsd/__init__.py @@ -0,0 +1 @@ +"""Tests for the GPSD integration.""" diff --git a/tests/components/gpsd/conftest.py b/tests/components/gpsd/conftest.py new file mode 100644 index 00000000000..c2bd2b8564a --- /dev/null +++ b/tests/components/gpsd/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the GPSD 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.gpsd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/gpsd/test_config_flow.py b/tests/components/gpsd/test_config_flow.py new file mode 100644 index 00000000000..0b0465b026d --- /dev/null +++ b/tests/components/gpsd/test_config_flow.py @@ -0,0 +1,76 @@ +"""Test the GPSD config flow.""" +from unittest.mock import AsyncMock, patch + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.gpsd.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +HOST = "gpsd.local" + + +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 + + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"GPS {HOST}" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_PORT: DEFAULT_PORT, + } + mock_setup_entry.assert_called_once() + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test connection to host error.""" + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.side_effect = OSError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "nonexistent.local", CONF_PORT: 1234}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + with patch("homeassistant.components.gpsd.config_flow.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_PORT: 1234, CONF_NAME: "MyGPS"}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "MyGPS" + assert result["data"] == { + CONF_HOST: HOST, + CONF_NAME: "MyGPS", + CONF_PORT: 1234, + } diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index a9fc5312bba..3873695033e 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -25,7 +25,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def gpslogger_client(event_loop, hass, hass_client_no_auth): +async def gpslogger_client(hass, hass_client_no_auth): """Mock client for GPSLogger (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -37,7 +37,7 @@ async def gpslogger_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index e28582ca2e9..fa35b5c1111 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -32,7 +32,7 @@ 'none', 'sleep', ]), - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -110,7 +110,7 @@ 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aabbcc112233', 'unit_of_measurement': None, diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 2a1baef6798..ecc21ab0cd2 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -386,7 +386,7 @@ async def test_state_missing_entity_id(hass: HomeAssistant, setup_comp) -> None: async def test_setup_before_started(hass: HomeAssistant) -> None: """Test we can setup before starting.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component(hass, DOMAIN, CONFIG_MISSING_FAN) await hass.async_block_till_done() diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 5c48385c91e..cb5143d5a12 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1139,7 +1139,7 @@ async def test_group_alarm(hass: HomeAssistant) -> None: hass.states.async_set("alarm_control_panel.one", "armed_away") hass.states.async_set("alarm_control_panel.two", "armed_home") hass.states.async_set("alarm_control_panel.three", "armed_away") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, @@ -1187,7 +1187,7 @@ async def test_group_vacuum_off(hass: HomeAssistant) -> None: hass.states.async_set("vacuum.one", "docked") hass.states.async_set("vacuum.two", "off") hass.states.async_set("vacuum.three", "off") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, @@ -1280,7 +1280,7 @@ async def test_switch_removed(hass: HomeAssistant) -> None: hass.states.async_set("switch.two", "off") hass.states.async_set("switch.three", "on") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, "group", @@ -1409,7 +1409,7 @@ async def test_group_that_references_a_group_of_lights(hass: HomeAssistant) -> N "light.living_front_ri", "light.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "off") @@ -1443,7 +1443,7 @@ async def test_group_that_references_a_group_of_covers(hass: HomeAssistant) -> N "cover.living_front_ri", "cover.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "closed") @@ -1479,7 +1479,7 @@ async def test_group_that_references_two_groups_of_covers(hass: HomeAssistant) - "cover.living_front_ri", "cover.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "closed") @@ -1523,7 +1523,7 @@ async def test_group_that_references_two_types_of_groups(hass: HomeAssistant) -> "device_tracker.living_front_ri", "device_tracker.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in group_1_entity_ids: hass.states.async_set(entity_id, "closed") diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 71a53042938..ec6905a500f 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -1,4 +1,5 @@ """The tests for the Group Sensor platform.""" + from __future__ import annotations from math import prod @@ -27,11 +28,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -62,7 +65,7 @@ PRODUCT_VALUE = prod(VALUES) ("product", PRODUCT_VALUE, {}), ], ) -async def test_sensors( +async def test_sensors2( hass: HomeAssistant, entity_registry: er.EntityRegistry, sensor_type: str, @@ -88,7 +91,7 @@ async def test_sensors( value, { ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIT_OF_MEASUREMENT: "L", }, ) @@ -105,7 +108,7 @@ async def test_sensors( assert state.attributes.get(key) == value assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}") @@ -146,7 +149,8 @@ async def test_sensors_attributes_defined(hass: HomeAssistant) -> None: state = hass.states.get("sensor.sensor_group_sum") - assert state.state == str(float(SUM_VALUE)) + # Liter to M3 = 1:0.001 + assert state.state == str(float(SUM_VALUE * 0.001)) assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -243,15 +247,15 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("sensor.second_test") -async def test_sensor_incorrect_state( +async def test_sensor_incorrect_state_with_ignore_non_numeric( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test the min sensor.""" + """Test that non numeric values are ignored in a group.""" config = { SENSOR_DOMAIN: { "platform": GROUP_DOMAIN, - "name": "test_failure", - "type": "min", + "name": "test_ignore_non_numeric", + "type": "max", "ignore_non_numeric": True, "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], "unique_id": "very_unique_id", @@ -264,24 +268,63 @@ async def test_sensor_incorrect_state( entity_ids = config["sensor"]["entities"] + # Check that the final sensor value ignores the non numeric input + for entity_id, value in dict(zip(entity_ids, VALUES_ERROR)).items(): + hass.states.async_set(entity_id, value) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_ignore_non_numeric") + assert state.state == "17.0" + assert ( + "Unable to use state. Only numerical states are supported," not in caplog.text + ) + + # Check that the final sensor value with all numeric inputs + for entity_id, value in dict(zip(entity_ids, VALUES)).items(): + hass.states.async_set(entity_id, value) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_ignore_non_numeric") + assert state.state == "20.0" + + +async def test_sensor_incorrect_state_with_not_ignore_non_numeric( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that non numeric values cause a group to be unknown.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_failure", + "type": "max", + "ignore_non_numeric": False, + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", + "state_class": SensorStateClass.MEASUREMENT, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + entity_ids = config["sensor"]["entities"] + + # Check that the final sensor value is unavailable if a non numeric input exists for entity_id, value in dict(zip(entity_ids, VALUES_ERROR)).items(): hass.states.async_set(entity_id, value) await hass.async_block_till_done() state = hass.states.get("sensor.test_failure") + assert state.state == "unknown" + assert "Unable to use state. Only numerical states are supported" in caplog.text - assert state.state == "15.3" - assert ( - "Unable to use state. Only numerical states are supported, entity sensor.test_2 with value string excluded from calculation" - in caplog.text - ) - + # Check that the final sensor value is correct with all numeric inputs for entity_id, value in dict(zip(entity_ids, VALUES)).items(): hass.states.async_set(entity_id, value) await hass.async_block_till_done() state = hass.states.get("sensor.test_failure") - assert state.state == "15.3" + assert state.state == "20.0" async def test_sensor_require_all_states(hass: HomeAssistant) -> None: @@ -324,9 +367,6 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity_ids = config["sensor"]["entities"] hass.states.async_set( @@ -334,7 +374,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: VALUES[0], { "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.MEASUREMENT, + "state_class": SensorStateClass.TOTAL, "unit_of_measurement": "kWh", }, ) @@ -343,34 +383,264 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: VALUES[1], { "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.MEASUREMENT, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "Wh", + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(float(sum([VALUES[0], VALUES[1], VALUES[2] / 1000]))) + assert state.attributes.get("device_class") == "energy" + assert state.attributes.get("state_class") == "total" + assert state.attributes.get("unit_of_measurement") == "kWh" + + # Test that a change of source entity's unit of measurement + # is converted correctly by the group sensor + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, "unit_of_measurement": "kWh", }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test_sum") - assert state.state == str(float(sum([VALUES[0], VALUES[1]]))) + assert state.state == str(float(sum(VALUES))) + + +async def test_sensor_calculated_properties_not_same( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the sensor calculating device_class, state_class and unit of measurement not same.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.CURRENT, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "A", + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(float(sum(VALUES))) + assert state.attributes.get("device_class") is None + assert state.attributes.get("state_class") is None + assert state.attributes.get("unit_of_measurement") is None + + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_uoms_not_matching_no_device_class" + ) + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_device_classes_not_matching" + ) + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_state_classes_not_matching" + ) + + +async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> None: + """Test the sensor calculating fails as UoM not part of device class.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(float(sum(VALUES))) assert state.attributes.get("device_class") == "energy" - assert state.attributes.get("state_class") == "measurement" + assert state.attributes.get("state_class") == "total" assert state.attributes.get("unit_of_measurement") == "kWh" + hass.states.async_set( + entity_ids[2], + 12, + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + }, + True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("device_class") == "energy" + assert state.attributes.get("state_class") == "total" + assert state.attributes.get("unit_of_measurement") == "kWh" + + +async def test_sensor_calculated_properties_not_convertible_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor calculating device_class, state_class and unit of measurement when device class not convertible.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(sum(VALUES)) + assert state.attributes.get("device_class") == "humidity" + assert state.attributes.get("state_class") == "measurement" + assert state.attributes.get("unit_of_measurement") == "%" + + assert ( + "Unable to use state. Only entities with correct unit of measurement is" + " supported when having a device class" + ) not in caplog.text + hass.states.async_set( entity_ids[2], VALUES[2], { - "device_class": SensorDeviceClass.BATTERY, - "state_class": SensorStateClass.TOTAL, - "unit_of_measurement": None, + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test_sum") - assert state.state == str(sum(VALUES)) - assert state.attributes.get("device_class") is None - assert state.attributes.get("state_class") is None - assert state.attributes.get("unit_of_measurement") is None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("device_class") == "humidity" + assert state.attributes.get("state_class") == "measurement" + assert state.attributes.get("unit_of_measurement") == "%" + + assert ( + "Unable to use state. Only entities with correct unit of measurement is" + " supported when having a device class, entity sensor.test_3, value 15.3 with" + " device class humidity and unit of measurement None excluded from calculation" + " in sensor.test_sum" + ) in caplog.text async def test_last_sensor(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 0cce33f6dfd..e54fdcafd1d 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -58,7 +58,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): ), patch( "homeassistant.components.hassio.HassIO.refresh_updates", ): - hass.state = CoreState.starting + hass.set_state(CoreState.starting) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) return hass_api.call_args[0][1] diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4bf3e29154e..fe8eeb0b0f6 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -245,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -290,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -309,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -326,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -406,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -423,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -443,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -525,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 24 + assert aioclient_mock.call_count == 23 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 25 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -547,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 27 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -572,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 29 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -591,7 +591,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 31 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -607,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -625,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 33 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -702,12 +702,12 @@ async def test_service_calls_core( await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 5 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 5 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -716,7 +716,7 @@ async def test_service_calls_core( await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 6 async def test_entry_load_and_unload(hass: HomeAssistant) -> None: @@ -897,14 +897,17 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Initial refresh without stats - assert refresh_updates_mock.call_count == 1 + + # Initial refresh, no update refresh call + assert refresh_updates_mock.call_count == 0 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", ) as refresh_updates_mock: async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() + + # Scheduled refresh, no update refresh call assert refresh_updates_mock.call_count == 0 with patch( @@ -921,13 +924,14 @@ async def test_coordinator_updates( }, blocking=True, ) - assert refresh_updates_mock.call_count == 0 - # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer - async_fire_time_changed( - hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) - ) - await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + assert refresh_updates_mock.call_count == 0 + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", @@ -968,14 +972,14 @@ async def test_coordinator_updates_stats_entities_enabled( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Initial refresh without stats - assert refresh_updates_mock.call_count == 1 + assert refresh_updates_mock.call_count == 0 # Refresh with stats once we know which ones are needed async_fire_time_changed( hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) ) await hass.async_block_till_done() - assert refresh_updates_mock.call_count == 2 + assert refresh_updates_mock.call_count == 1 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", @@ -1059,7 +1063,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 28228788cf5..21580c48f33 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -486,7 +486,7 @@ async def test_route_not_found( async def test_restore_state(hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) last_reset = "2022-11-29T00:00:00.000000+00:00" mock_restore_cache_with_extra_data( hass, diff --git a/tests/components/hko/__init__.py b/tests/components/hko/__init__.py new file mode 100644 index 00000000000..ff4447c26e5 --- /dev/null +++ b/tests/components/hko/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hong Kong Observatory integration.""" diff --git a/tests/components/hko/conftest.py b/tests/components/hko/conftest.py new file mode 100644 index 00000000000..fd2181ddfc9 --- /dev/null +++ b/tests/components/hko/conftest.py @@ -0,0 +1,17 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(name="hko_config_flow_connect", autouse=True) +def hko_config_flow_connect(): + """Mock valid config flow setup.""" + with patch( + "homeassistant.components.hko.config_flow.HKO.weather", + return_value=json.loads(load_fixture("hko/rhrread.json")), + ): + yield diff --git a/tests/components/hko/fixtures/rhrread.json b/tests/components/hko/fixtures/rhrread.json new file mode 100644 index 00000000000..f9c0090ef6a --- /dev/null +++ b/tests/components/hko/fixtures/rhrread.json @@ -0,0 +1,82 @@ +{ + "rainfall": { + "data": [ + { + "unit": "mm", + "place": "Central & Western District", + "max": 0, + "main": "FALSE" + }, + { "unit": "mm", "place": "Eastern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwai Tsing", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Islands District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "North District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sai Kung", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sha Tin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Southern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tai Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tsuen Wan", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tuen Mun", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wan Chai", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yuen Long", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yau Tsim Mong", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sham Shui Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kowloon City", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wong Tai Sin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwun Tong", "max": 0, "main": "FALSE" } + ], + "startTime": "2023-08-19T15:45:00+08:00", + "endTime": "2023-08-19T16:45:00+08:00" + }, + "icon": [60], + "iconUpdateTime": "2023-08-19T16:40:00+08:00", + "uvindex": { + "data": [{ "place": "King's Park", "value": 2, "desc": "low" }], + "recordDesc": "During the past hour" + }, + "updateTime": "2023-08-19T17:02:00+08:00", + "temperature": { + "data": [ + { "place": "King's Park", "value": 30, "unit": "C" }, + { "place": "Hong Kong Observatory", "value": 29, "unit": "C" }, + { "place": "Wong Chuk Hang", "value": 29, "unit": "C" }, + { "place": "Ta Kwu Ling", "value": 31, "unit": "C" }, + { "place": "Lau Fau Shan", "value": 31, "unit": "C" }, + { "place": "Tai Po", "value": 29, "unit": "C" }, + { "place": "Sha Tin", "value": 31, "unit": "C" }, + { "place": "Tuen Mun", "value": 28, "unit": "C" }, + { "place": "Tseung Kwan O", "value": 29, "unit": "C" }, + { "place": "Sai Kung", "value": 29, "unit": "C" }, + { "place": "Cheung Chau", "value": 27, "unit": "C" }, + { "place": "Chek Lap Kok", "value": 30, "unit": "C" }, + { "place": "Tsing Yi", "value": 29, "unit": "C" }, + { "place": "Shek Kong", "value": 31, "unit": "C" }, + { "place": "Tsuen Wan Ho Koon", "value": 27, "unit": "C" }, + { "place": "Tsuen Wan Shing Mun Valley", "value": 29, "unit": "C" }, + { "place": "Hong Kong Park", "value": 29, "unit": "C" }, + { "place": "Shau Kei Wan", "value": 29, "unit": "C" }, + { "place": "Kowloon City", "value": 30, "unit": "C" }, + { "place": "Happy Valley", "value": 32, "unit": "C" }, + { "place": "Wong Tai Sin", "value": 31, "unit": "C" }, + { "place": "Stanley", "value": 29, "unit": "C" }, + { "place": "Kwun Tong", "value": 30, "unit": "C" }, + { "place": "Sham Shui Po", "value": 30, "unit": "C" }, + { "place": "Kai Tak Runway Park", "value": 30, "unit": "C" }, + { "place": "Yuen Long Park", "value": 29, "unit": "C" }, + { "place": "Tai Mei Tuk", "value": 29, "unit": "C" } + ], + "recordTime": "2023-08-19T17:00:00+08:00" + }, + "warningMessage": "", + "mintempFrom00To09": "", + "rainfallFrom00To12": "", + "rainfallLastMonth": "", + "rainfallJanuaryToLastMonth": "", + "tcmessage": "", + "humidity": { + "recordTime": "2023-08-19T17:00:00+08:00", + "data": [ + { "unit": "percent", "value": 74, "place": "Hong Kong Observatory" } + ] + } +} diff --git a/tests/components/hko/test_config_flow.py b/tests/components/hko/test_config_flow.py new file mode 100644 index 00000000000..ce32d2cd0da --- /dev/null +++ b/tests/components/hko/test_config_flow.py @@ -0,0 +1,112 @@ +"""Test the Hong Kong Observatory config flow.""" + +from unittest.mock import patch + +from hko import HKOError + +from homeassistant.components.hko.const import DEFAULT_LOCATION, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LOCATION +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_config_flow_default(hass: HomeAssistant) -> None: + """Test user config flow with default fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_LOCATION + assert result2["result"].unique_id == DEFAULT_LOCATION + assert result2["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test user config flow without connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = HKOError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_timeout(hass: HomeAssistant) -> None: + """Test user config flow with timedout connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test user config flow with two equal entries.""" + r1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r1["type"] == FlowResultType.FORM + assert r1["step_id"] == SOURCE_USER + assert "flow_id" in r1 + result1 = await hass.config_entries.flow.async_configure( + r1["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result1["type"] == FlowResultType.CREATE_ENTRY + + r2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r2["type"] == FlowResultType.FORM + assert r2["step_id"] == SOURCE_USER + assert "flow_id" in r2 + result2 = await hass.config_entries.flow.async_configure( + r2["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 9cd45f18270..209100c71b2 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,12 +3,15 @@ from http import HTTPStatus from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.home_connect.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -26,16 +29,10 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "home_connect", - { - "home_connect": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - }, - "http": {"base_url": "https://example.com"}, - }, + assert await setup.async_setup_component(hass, "home_connect", {}) + + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/home_plus_control/__init__.py b/tests/components/home_plus_control/__init__.py deleted file mode 100644 index a9caba13e32..00000000000 --- a/tests/components/home_plus_control/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Legrand Home+ Control integration.""" diff --git a/tests/components/home_plus_control/conftest.py b/tests/components/home_plus_control/conftest.py deleted file mode 100644 index 6ac856a3227..00000000000 --- a/tests/components/home_plus_control/conftest.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Test setup and fixtures for component Home+ Control by Legrand.""" -from homepluscontrol.homeplusinteractivemodule import HomePlusInteractiveModule -from homepluscontrol.homeplusplant import HomePlusPlant -import pytest - -from homeassistant.components.home_plus_control.const import DOMAIN - -from tests.common import MockConfigEntry - -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -SUBSCRIPTION_KEY = "12345678901234567890123456789012" - - -@pytest.fixture -def mock_config_entry(): - """Return a fake config entry. - - This is a minimal entry to setup the integration and to ensure that the - OAuth access token will not expire. - """ - return MockConfigEntry( - domain=DOMAIN, - title="Home+ Control", - data={ - "auth_implementation": "home_plus_control", - "token": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 9999999999, - "expires_at": 9999999999.99999999, - "expires_on": 9999999999, - }, - }, - source="test", - options={}, - unique_id=DOMAIN, - entry_id="home_plus_control_entry_id", - ) - - -@pytest.fixture -def mock_modules(): - """Return the full set of mock modules.""" - plant = HomePlusPlant( - id="123456789009876543210", name="My Home", country="ES", oauth_client=None - ) - modules = { - "0000000987654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000987654321fedcba", - name="Kitchen Wall Outlet", - hw_type="NLP", - device="plug", - fw="42", - reachable=True, - ), - "0000000887654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000887654321fedcba", - name="Bedroom Wall Outlet", - hw_type="NLP", - device="light", - fw="42", - reachable=True, - ), - "0000000787654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000787654321fedcba", - name="Living Room Ceiling Light", - hw_type="NLF", - device="light", - fw="46", - reachable=True, - ), - "0000000687654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000687654321fedcba", - name="Dining Room Ceiling Light", - hw_type="NLF", - device="light", - fw="46", - reachable=True, - ), - "0000000587654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000587654321fedcba", - name="Dining Room Wall Outlet", - hw_type="NLP", - device="plug", - fw="42", - reachable=True, - ), - } - - # Set lights off and plugs on - for mod_stat in modules.values(): - mod_stat.status = "on" - if mod_stat.device == "light": - mod_stat.status = "off" - - return modules diff --git a/tests/components/home_plus_control/test_config_flow.py b/tests/components/home_plus_control/test_config_flow.py deleted file mode 100644 index 19d12b946e8..00000000000 --- a/tests/components/home_plus_control/test_config_flow.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Test the Legrand Home+ Control config flow.""" -from http import HTTPStatus -from unittest.mock import patch - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -) -> None: - """Check full flow.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "auth" - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - with patch( - "homeassistant.components.home_plus_control.async_setup_entry", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Home+ Control" - config_data = result["data"] - assert config_data["token"]["refresh_token"] == "mock-refresh-token" - assert config_data["token"]["access_token"] == "mock-access-token" - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - - -async def test_abort_if_entry_in_progress( - hass: HomeAssistant, current_request_with_host: None -) -> None: - """Check flow abort when an entry is already in progress.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - - # Start one flow - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - # Attempt to start another flow - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_in_progress" - - -async def test_abort_if_entry_exists( - hass: HomeAssistant, current_request_with_host: None -) -> None: - """Check flow abort when an entry already exists.""" - existing_entry = MockConfigEntry(domain=DOMAIN) - existing_entry.add_to_hass(hass) - - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - "http": {}, - }, - ) - - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_abort_if_invalid_token( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -) -> None: - """Check flow abort when the token has an invalid value.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "auth" - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": "non-integer", - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "oauth_error" diff --git a/tests/components/home_plus_control/test_init.py b/tests/components/home_plus_control/test_init.py deleted file mode 100644 index 962ae416aa5..00000000000 --- a/tests/components/home_plus_control/test_init.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test the Legrand Home+ Control integration.""" -from unittest.mock import patch - -from homeassistant import config_entries, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, -) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - - -async def test_loading(hass: HomeAssistant, mock_config_entry) -> None: - """Test component loading.""" - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value={}, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - - assert len(mock_check.mock_calls) == 1 - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - -async def test_loading_with_no_config(hass: HomeAssistant, mock_config_entry) -> None: - """Test component loading failure when it has not configuration.""" - mock_config_entry.add_to_hass(hass) - await setup.async_setup_component(hass, DOMAIN, {}) - # Component setup fails because the oauth2 implementation could not be registered - assert mock_config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR - - -async def test_unloading(hass: HomeAssistant, mock_config_entry) -> None: - """Test component unloading.""" - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value={}, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - - assert len(mock_check.mock_calls) == 1 - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # We now unload the entry - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py deleted file mode 100644 index d41977d57a9..00000000000 --- a/tests/components/home_plus_control/test_switch.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Test the Legrand Home+ Control switch platform.""" -import datetime as dt -from unittest.mock import patch - -from homepluscontrol.homeplusapi import HomePlusControlApiError - -from homeassistant import config_entries, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, -) -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - -from tests.common import async_fire_time_changed - - -def entity_assertions( - hass, - num_exp_entities, - num_exp_devices=None, - expected_entities=None, - expected_devices=None, -): - """Assert number of entities and devices.""" - entity_reg = er.async_get(hass) - device_reg = dr.async_get(hass) - - if num_exp_devices is None: - num_exp_devices = num_exp_entities - - assert len(entity_reg.entities) == num_exp_entities - assert len(device_reg.devices) == num_exp_devices - - if expected_entities is not None: - for exp_entity_id, present in expected_entities.items(): - assert bool(entity_reg.async_get(exp_entity_id)) == present - - if expected_devices is not None: - for exp_device_id, present in expected_devices.items(): - assert bool(device_reg.async_get(exp_device_id)) == present - - -def one_entity_state(hass, device_uid): - """Assert the presence of an entity and return its state.""" - entity_reg = er.async_get(hass) - device_reg = dr.async_get(hass) - - device_id = device_reg.async_get_device(identifiers={(DOMAIN, device_uid)}).id - entity_entries = er.async_entries_for_device(entity_reg, device_id) - - assert len(entity_entries) == 1 - entity_entry = entity_entries[0] - return hass.states.get(entity_entry.entity_id).state - - -async def test_plant_update( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test entity and device loading.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - -async def test_plant_topology_reduction_change( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an entity leaving the plant topology.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - 5 mock entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Now we refresh the topology with one entity less - mock_modules.pop("0000000987654321fedcba") - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check for plant, topology and module status - this time only 4 left - entity_assertions( - hass, - num_exp_entities=4, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": False, - }, - ) - - -async def test_plant_topology_increase_change( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an entity entering the plant topology.""" - # Remove one module initially - new_module = mock_modules.pop("0000000987654321fedcba") - - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - we have 4 entities to start with - entity_assertions( - hass, - num_exp_entities=4, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": False, - }, - ) - - # Now we refresh the topology with one entity more - mock_modules["0000000987654321fedcba"] = new_module - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - -async def test_module_status_unavailable( - hass: HomeAssistant, mock_config_entry, mock_modules -) -> None: - """Test a module becoming unreachable in the plant.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - 5 mock entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Confirm the availability of this particular entity - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_ON - - # Now we refresh the topology with the module being unreachable - mock_modules["0000000987654321fedcba"].reachable = False - - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - await hass.async_block_till_done() - # The entity is present, but not available - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE - - -async def test_module_status_available( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test a module becoming reachable in the plant.""" - # Set the module initially unreachable - mock_modules["0000000987654321fedcba"].reachable = False - - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # This particular entity is not available - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE - - # Now we refresh the topology with the module being reachable - mock_modules["0000000987654321fedcba"].reachable = True - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities remain the same - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Now the entity is available - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_ON - - -async def test_initial_api_error( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an API error on initial call.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - side_effect=HomePlusControlApiError, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # The component has been loaded - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # Check the entities and devices - None have been configured - entity_assertions(hass, num_exp_entities=0) - - -async def test_update_with_api_error( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an API timeout when updating the module data.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # The component has been loaded - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # Check the entities and devices - all entities should be there - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - for test_entity_uid in mock_modules: - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state in (STATE_ON, STATE_OFF) - - # Attempt to update the data, but API update fails - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - side_effect=HomePlusControlApiError, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - all should still be present - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # This entity has not returned a status, so appears as unavailable - for test_entity_uid in mock_modules: - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 22b380a3249..be29f3a3032 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -256,7 +256,7 @@ async def test_turn_on_skips_domains_without_service( "turn_on", {"entity_id": ["light.test", "sensor.bla", "binary_sensor.blub", "light.bla"]}, ) - service = hass.services._services["homeassistant"]["turn_on"] + service = hass.services.async_services_for_domain("homeassistant")["turn_on"] with patch( "homeassistant.core.ServiceRegistry.async_call", diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 6ac8291ee55..9a202bc99a1 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -31,7 +31,7 @@ async def test_if_fires_on_hass_start( ) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component(hass, automation.DOMAIN, hass_config) assert automation.is_on(hass, "automation.hello") @@ -54,7 +54,7 @@ async def test_if_fires_on_hass_start( async def test_if_fires_on_hass_shutdown(hass: HomeAssistant) -> None: """Test the firing when Home Assistant shuts down.""" calls = async_mock_service(hass, "test", "automation") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component( hass, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index f58d561bfb3..43fcd69e4db 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -242,9 +242,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -263,9 +261,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -321,9 +317,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( @@ -362,9 +356,7 @@ async def test_option_flow_install_multi_pan_addon_zha( } assert zha_config_entry.title == "Test Multiprotocol" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -420,9 +412,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") addon_info.return_value["hostname"] = "core-silabs-multiprotocol" @@ -442,9 +432,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -700,19 +688,15 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["step_id"] == "install_flasher_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_flasher_addon" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -720,10 +704,8 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_flasher") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -878,10 +860,8 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -894,10 +874,8 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed "available": True, "state": "not_running", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() install_addon.assert_not_called() - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -964,9 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["step_id"] == "install_flasher_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "install_failed" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_flasher") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1019,10 +995,8 @@ async def test_option_flow_flasher_addon_flash_failure( start_addon.side_effect = HassioAPIError("Boom") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -1030,10 +1004,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - addon_store_info.return_value["installed"] = True - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flasher_failed" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -1158,10 +1129,8 @@ async def test_option_flow_uninstall_migration_finish_failure( result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -1169,9 +1138,7 @@ async def test_option_flow_uninstall_migration_finish_failure( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -1242,9 +1209,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "install_failed" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1287,9 +1252,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1308,9 +1271,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_failed" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1353,9 +1314,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1432,9 +1391,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1490,9 +1447,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1511,9 +1466,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 65636b27a16..36f0a259b7f 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -210,9 +210,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -231,9 +229,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -313,9 +309,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -343,9 +337,7 @@ async def test_option_flow_install_multi_pan_addon_zha( "radio_type": "ezsp", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 242b316de66..bd61400fa8e 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -143,9 +143,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -164,9 +162,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -225,9 +221,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -255,9 +249,7 @@ async def test_option_flow_install_multi_pan_addon_zha( "radio_type": "ezsp", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index b925fcb341c..838f72be3c6 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1444,7 +1444,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( # sonos_config_switch.entity_id is a config category entity # so it should not be selectable since it will always be excluded - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result2["flow_id"], user_input={"entities": [sonos_config_switch.entity_id]}, @@ -1539,7 +1539,7 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( # sonos_hidden_switch.entity_id is a hidden entity # so it should not be selectable since it will always be excluded - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result2["flow_id"], user_input={"entities": [sonos_hidden_switch.entity_id]}, diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index a44db05a37b..bc43ebaf42f 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -368,22 +368,22 @@ async def test_windowcovering_cover_set_tilt( assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: None}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 100}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 90 assert acc.char_target_tilt.value == 90 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 50}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 0}) await hass.async_block_till_done() assert acc.char_current_tilt.value == -90 assert acc.char_target_tilt.value == -90 @@ -610,7 +610,7 @@ async def test_windowcovering_basic_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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "cover", @@ -648,7 +648,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "cover", diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 118e67a43b1..edaa277576e 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -557,7 +557,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "fan", diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 7568e7a4844..80555750640 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -580,7 +580,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="simple" diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index dc614ee54c4..7bdfd6c5803 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -66,14 +66,14 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_current_state.value == 3 + assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 0 # Unavailable should keep last state # but set the accessory to not available hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() - assert acc.char_current_state.value == 3 + assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 0 assert acc.available is False diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 1954d6bf8ca..8089db833e8 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -432,7 +432,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "media_player", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 23e53eef94d..fae963c81f5 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -545,7 +545,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "sensor", diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 5bfbe0b1627..3f5d939bb82 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1,6 +1,7 @@ """Test different accessory types: Thermostats.""" from unittest.mock import patch +from pyhap.characteristic import Characteristic from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -68,6 +69,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, ) from homeassistant.core import CoreState, HomeAssistant @@ -968,7 +971,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "climate", "generic", "1234", suggested_object_id="simple" @@ -1798,7 +1801,7 @@ 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 + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "water_heater", "generic", "1234", suggested_object_id="simple" @@ -2446,3 +2449,144 @@ async def test_thermostat_with_supported_features_target_temp_but_fan_mode_set( assert acc.ordered_fan_speeds == [] assert not acc.fan_chars + + +async def test_thermostat_handles_unknown_state( + hass: HomeAssistant, hk_driver, events +) -> None: + """Test a thermostat can handle unknown state.""" + entity_id = "climate.test" + attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_MIN_TEMP: 44.6, + ATTR_MAX_TEMP: 95, + ATTR_PRESET_MODES: ["home", "away"], + ATTR_TEMPERATURE: 67, + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_FAN_MODES: None, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_PRESET_MODE: "home", + ATTR_FRIENDLY_NAME: "Rec Room", + ATTR_HVAC_MODES: [ + HVACMode.OFF, + HVACMode.HEAT, + ], + } + + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + heat_cool_char: Characteristic = acc.char_target_heat_cool + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is False + + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is False + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: heat_cool_char.to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: HC_HEAT_COOL_HEAT, + } + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is False + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is True + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: heat_cool_char.to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: HC_HEAT_COOL_HEAT, + } + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is True + assert call_set_hvac_mode + assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT 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 00000000000..cfb94b104b0 --- /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 00000000000..4526179b4da --- /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 00000000000..2e5c8719876 --- /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 00000000000..d58de1d2b98 --- /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 00000000000..f96d168fc5f --- /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 00000000000..8dd33639190 --- /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 00000000000..28ef6c91d25 --- /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 00000000000..b5614184fae --- /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 index d3205b09de3..bda92943cce 100644 --- a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -43,10 +43,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, @@ -360,10 +367,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a0c6fd00ee6..29b71d18422 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1626,7 +1626,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -1656,10 +1661,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -2014,7 +2026,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -2044,10 +2061,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'ArloBabyA0 Nightlight', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -2453,7 +2477,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': 'TestData', 'device_class': None, @@ -2481,7 +2505,7 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'InWall Outlet-0394DE Energy kWh', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', @@ -2494,7 +2518,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': 'TestData', 'device_class': None, @@ -2522,7 +2546,7 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'InWall Outlet-0394DE Energy kWh', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', @@ -3043,7 +3067,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -3065,7 +3089,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -3751,7 +3775,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -3773,7 +3797,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -4164,7 +4188,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -4186,7 +4210,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -4829,7 +4853,7 @@ 'original_name': 'My ecobee', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -4856,7 +4880,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': 25.6, 'target_temp_low': 7.2, 'temperature': None, @@ -6230,6 +6254,368 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_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_basic_fan] list([ dict({ @@ -6519,6 +6905,958 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_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, + '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': , + '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_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({ @@ -6990,6 +8328,1082 @@ }), ]) # --- +# 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({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + '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, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Laundry Smoke ED78', + 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + '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', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[homespan_daikin_bridge] list([ dict({ @@ -7095,7 +9509,7 @@ 'original_name': 'Air Conditioner SlaveID 1', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', 'unit_of_measurement': None, @@ -7120,7 +9534,7 @@ ]), 'max_temp': 32, 'min_temp': 18, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 24.5, }), @@ -9157,7 +11571,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -9187,10 +11606,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -9633,7 +12059,7 @@ 'original_name': 'Lennox', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100', 'unit_of_measurement': None, @@ -9652,7 +12078,7 @@ ]), 'max_temp': 37, 'min_temp': 4.5, - 'supported_features': , + 'supported_features': , 'target_temp_high': 29.5, 'target_temp_low': 21, 'temperature': None, @@ -10601,7 +13027,7 @@ 'original_name': 'Mysa-85dda9 Thermostat', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20', 'unit_of_measurement': None, @@ -10620,7 +13046,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'temperature': None, }), 'entity_id': 'climate.mysa_85dda9_thermostat', @@ -13940,7 +16366,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -13970,17 +16401,24 @@ 'attributes': dict({ 'brightness': 127.5, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'hs_color': tuple( 120.0, 100.0, ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': tuple( 0, 255, 0, ), 'supported_color_modes': list([ + , , ]), 'supported_features': , diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 9e08c6fed0a..94a91bb0417 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -50,7 +50,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), @@ -80,7 +80,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), @@ -129,7 +129,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), @@ -159,7 +159,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), 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 00000000000..87948c92214 --- /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 723881ac182..a4bcf7e962e 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -107,6 +107,8 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ), capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], 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 00000000000..5d0f63b07ff --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py @@ -0,0 +1,53 @@ +"""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.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + 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 + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + 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 00000000000..518bcbbef38 --- /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_light_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py new file mode 100644 index 00000000000..4e62c75d8f2 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py @@ -0,0 +1,45 @@ +"""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.COLOR_TEMP, + ColorMode.HS, + ] diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 3412e41aa17..8da6290d914 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -12,7 +12,7 @@ from aiohomekit.model.services import ServicesTypes from bleak.exc import BleakError import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES @@ -485,6 +485,44 @@ async def test_discovery_invalid_config_entry(hass: HomeAssistant, controller) - assert result["type"] == "form" +async def test_discovery_ignored_config_entry(hass: HomeAssistant, controller) -> None: + """There is already a config entry but it is ignored.""" + pairing = await controller.add_paired_device(Accessories(), "00:00:00:00:00:00") + + MockConfigEntry( + domain="homekit_controller", + data={}, + unique_id="00:00:00:00:00:00", + source=config_entries.SOURCE_IGNORE, + ).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Device is discovered + with patch.object( + pairing, + "list_accessories_and_characteristics", + side_effect=AuthenticationError("Invalid pairing keys"), + ): + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Entry is still ignored + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 1 + + # We should abort since there is no accessory id in the data + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_discovery_already_configured(hass: HomeAssistant, controller) -> None: """Already configured.""" entry = MockConfigEntry( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 08169c006ae..35b88c1abbe 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -11,7 +11,7 @@ from homeassistant.components.homekit_controller.const import ( IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, ) -from homeassistant.components.thread import async_add_dataset +from homeassistant.components.thread import async_add_dataset, dataset_store from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -225,6 +225,9 @@ async def test_thread_provision(hass: HomeAssistant) -> None: "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8", ) + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 72bf579b36e..c7f168b2abe 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -74,6 +74,22 @@ async def test_switch_change_light_state(hass: HomeAssistant) -> None: }, ) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.testdevice", "brightness": 255, "color_temp": 300}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.BRIGHTNESS: 100, + CharacteristicsTypes.HUE: 27, + CharacteristicsTypes.SATURATION: 49, + }, + ) + await hass.services.async_call( "light", "turn_off", {"entity_id": "light.testdevice"}, blocking=True ) @@ -176,7 +192,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: state = await helper.poll_and_get_state() assert state.state == "off" assert state.attributes[ATTR_COLOR_MODE] is None - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that someone switched on the device in the real world not via HA @@ -193,7 +212,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: assert state.attributes["brightness"] == 255 assert state.attributes["hs_color"] == (4, 5) assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that device switched off in the real world not via HA @@ -205,6 +227,25 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: ) assert state.state == "off" + # Simulate that device switched on in the real world not via HA + state = await helper.async_update( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.HUE: 6, + CharacteristicsTypes.SATURATION: 7, + }, + ) + assert state.state == "on" + assert state.attributes["brightness"] == 255 + assert state.attributes["hs_color"] == (6, 7) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/data.json b/tests/components/homewizard/fixtures/HWE-KWH1/data.json new file mode 100644 index 00000000000..7f970de2cde --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/data.json @@ -0,0 +1,16 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 2.705, + "total_power_export_t1_kwh": 255.551, + "active_power_w": -1058.296, + "active_power_l1_w": -1058.296, + "active_voltage_v": 228.472, + "active_current_a": 0.273, + "active_apparent_current_a": 0.447, + "active_reactive_current_a": 0.354, + "active_apparent_power_va": 74.052, + "active_reactive_power_var": -58.612, + "active_power_factor": 0.611, + "active_frequency_hz": 50 +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/device.json b/tests/components/homewizard/fixtures/HWE-KWH1/device.json new file mode 100644 index 00000000000..67f9ddf42cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH1", + "product_name": "kWh meter", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/system.json b/tests/components/homewizard/fixtures/HWE-KWH1/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/data.json b/tests/components/homewizard/fixtures/HWE-KWH3/data.json new file mode 100644 index 00000000000..fc0d1e929f9 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/data.json @@ -0,0 +1,37 @@ +{ + "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, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/device.json b/tests/components/homewizard/fixtures/HWE-KWH3/device.json new file mode 100644 index 00000000000..e3122c8ff89 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH3", + "product_name": "KWh meter 3-phase", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/system.json b/tests/components/homewizard/fixtures/HWE-KWH3/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-P1/data.json b/tests/components/homewizard/fixtures/HWE-P1/data.json index 2eb7e3e430b..b221ad6f804 100644 --- a/tests/components/homewizard/fixtures/HWE-P1/data.json +++ b/tests/components/homewizard/fixtures/HWE-P1/data.json @@ -41,5 +41,42 @@ "montly_power_peak_w": 1111.0, "montly_power_peak_timestamp": 230101080010, "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567 + "total_liter_m3": 1234.567, + "external": [ + { + "unique_id": "47303031", + "type": "gas_meter", + "timestamp": 230125220957, + "value": 111.111, + "unit": "m3" + }, + { + "unique_id": "57303031", + "type": "water_meter", + "timestamp": 230125220957, + "value": 222.222, + "unit": "m3" + }, + { + "unique_id": "5757303031", + "type": "warm_water_meter", + "timestamp": 230125220957, + "value": 333.333, + "unit": "m3" + }, + { + "unique_id": "48303031", + "type": "heat_meter", + "timestamp": 230125220957, + "value": 444.444, + "unit": "GJ" + }, + { + "unique_id": "4948303031", + "type": "inlet_heat_meter", + "timestamp": 230125220957, + "value": 555.555, + "unit": "m3" + } + ] } diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/data.json b/tests/components/homewizard/fixtures/SDM230/SDM630/data.json new file mode 100644 index 00000000000..fc0d1e929f9 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/data.json @@ -0,0 +1,37 @@ +{ + "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, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 +} diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/device.json b/tests/components/homewizard/fixtures/SDM230/SDM630/device.json new file mode 100644 index 00000000000..b8ec1d18fe8 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/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/SDM230/SDM630/system.json b/tests/components/homewizard/fixtures/SDM230/SDM630/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json index 64fb2533359..7f970de2cde 100644 --- a/tests/components/homewizard/fixtures/SDM230/data.json +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -4,5 +4,13 @@ "total_power_import_t1_kwh": 2.705, "total_power_export_t1_kwh": 255.551, "active_power_w": -1058.296, - "active_power_l1_w": -1058.296 + "active_power_l1_w": -1058.296, + "active_voltage_v": 228.472, + "active_current_a": 0.273, + "active_apparent_current_a": 0.447, + "active_reactive_current_a": 0.354, + "active_apparent_power_va": 74.052, + "active_reactive_power_var": -58.612, + "active_power_factor": 0.611, + "active_frequency_hz": 50 } diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json index ee143220c67..fc0d1e929f9 100644 --- a/tests/components/homewizard/fixtures/SDM630/data.json +++ b/tests/components/homewizard/fixtures/SDM630/data.json @@ -6,5 +6,32 @@ "active_power_w": -900.194, "active_power_l1_w": -1058.296, "active_power_l2_w": 158.102, - "active_power_l3_w": 0.0 + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 } diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 01094ec2698..9e3a468d58f 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,24 +1,255 @@ # serializer version: 1 +# name: test_diagnostics[HWE-KWH1] + dict({ + 'data': dict({ + 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': 74.052, + 'active_current_a': 0.273, + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': 50, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_factor': 0.611, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': -1058.296, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': -58.612, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'active_voltage_v': 228.472, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 255.551, + 'total_energy_export_t1_kwh': 255.551, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 2.705, + 'total_energy_import_t1_kwh': 2.705, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'kWh meter', + 'product_type': 'HWE-KWH1', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[HWE-KWH3] + dict({ + 'data': dict({ + 'data': dict({ + 'active_apparent_power_l1_va': 0, + 'active_apparent_power_l2_va': 3548.879, + 'active_apparent_power_l3_va': 3563.414, + 'active_apparent_power_va': 7112.293, + 'active_current_a': 30.999, + 'active_current_l1_a': 0, + 'active_current_l2_a': 15.521, + 'active_current_l3_a': 15.477, + 'active_frequency_hz': 49.926, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': 1, + 'active_power_factor_l2': 0.999, + 'active_power_factor_l3': 0.997, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': 158.102, + 'active_power_l3_w': 0.0, + 'active_power_w': -900.194, + 'active_reactive_power_l1_var': 0, + 'active_reactive_power_l2_var': -166.675, + 'active_reactive_power_l3_var': -262.35, + 'active_reactive_power_var': -429.025, + 'active_tariff': None, + 'active_voltage_l1_v': 230.751, + 'active_voltage_l2_v': 228.391, + 'active_voltage_l3_v': 229.612, + 'active_voltage_v': None, + 'any_power_fail_count': None, + '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': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': -4, 'active_current_l2_a': 2, 'active_current_l3_a': 0, 'active_frequency_hz': 50, 'active_liter_lpm': 12.345, 'active_power_average_w': 123.0, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': -123, 'active_power_l2_w': 456, 'active_power_l3_w': 123.456, 'active_power_w': -123, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': 2, 'active_voltage_l1_v': 230.111, 'active_voltage_l2_v': 230.222, 'active_voltage_l3_v': 230.333, + 'active_voltage_v': None, 'any_power_fail_count': 4, - 'external_devices': None, + 'external_devices': dict({ + 'G001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 111.111, + }), + 'H001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'GJ', + 'value': 444.444, + }), + 'IH001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 555.555, + }), + 'W001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 222.222, + }), + 'WW001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 333.333, + }), + }), 'gas_timestamp': '2021-03-14T11:22:33', 'gas_unique_id': '**REDACTED**', 'long_power_fail_count': 5, @@ -72,20 +303,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': 1457.277, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': 1457.277, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -145,20 +390,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': 0, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': None, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': None, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -212,20 +471,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': 74.052, + 'active_current_a': 0.273, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'active_frequency_hz': 50, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': 0.611, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': -1058.296, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': -1058.296, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': -58.612, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': 228.472, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -281,20 +554,34 @@ 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_apparent_power_l1_va': 0, + 'active_apparent_power_l2_va': 3548.879, + 'active_apparent_power_l3_va': 3563.414, + 'active_apparent_power_va': 7112.293, + 'active_current_a': 30.999, + 'active_current_l1_a': 0, + 'active_current_l2_a': 15.521, + 'active_current_l3_a': 15.477, + 'active_frequency_hz': 49.926, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': 1, + 'active_power_factor_l2': 0.999, + 'active_power_factor_l3': 0.997, 'active_power_l1_w': -1058.296, 'active_power_l2_w': 158.102, 'active_power_l3_w': 0.0, 'active_power_w': -900.194, + 'active_reactive_power_l1_var': 0, + 'active_reactive_power_l2_var': -166.675, + 'active_reactive_power_l3_var': -262.35, + 'active_reactive_power_var': -429.025, 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, + 'active_voltage_l1_v': 230.751, + 'active_voltage_l2_v': 228.391, + 'active_voltage_l3_v': 229.612, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 5c7e71ea9ac..fc1c3c74a03 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Status light brightness', - 'icon': 'mdi:lightbulb-on', 'max': 100.0, 'min': 0.0, 'mode': , @@ -43,7 +42,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:lightbulb-on', + 'original_icon': None, 'original_name': 'Status light brightness', 'platform': 'homewizard', 'previous_unique_id': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index e237edee58e..f3a78d14d5b 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,3264 @@ # serializer version: 1 +# name: test_gas_meter_migrated[HWE-P1-unique_ids0][sensor.homewizard_aabbccddeeff_gas_unique_id: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.homewizard_aabbccddeeff_gas_unique_id', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_gas_unique_id', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[HWE-P1-unique_ids0][sensor.homewizard_aabbccddeeff_total_gas_m3: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.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_gas_unique_id][sensor.homewizard_a: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.homewizard_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_gas_unique_id][sensor.homewizard_aabbccddeeff_gas_unique_id: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.homewizard_aabbccddeeff_gas_unique_id', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_gas_unique_id', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_total_gas_m3][sensor.homewizard_a: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.homewizard_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_total_gas_m3][sensor.homewizard_aabbccddeeff_total_gas_m3: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.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[sensor.homewizard_aabbccddeeff_total_gas_m3: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.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current: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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current: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_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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-KWH1-entity_ids6][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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-KWH1-entity_ids6][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor: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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor: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_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_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_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage: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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage: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_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][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': None, + '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-KWH1-entity_ids6][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][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-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][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': None, + '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-KWH1-entity_ids6][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_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_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current: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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current: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_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '15.521', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '15.477', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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-KWH3-entity_ids7][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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-KWH3-entity_ids7][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '49.926', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_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_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_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_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '228.391', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_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_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': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '229.612', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][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': None, + '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-KWH3-entity_ids7][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][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-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][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': None, + '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-KWH3-entity_ids7][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -136,7 +3396,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l1_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l1_a', 'unit_of_measurement': , }) @@ -216,7 +3476,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l2_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l2_a', 'unit_of_measurement': , }) @@ -296,7 +3556,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l3_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l3_a', 'unit_of_measurement': , }) @@ -542,7 +3802,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -625,7 +3885,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -708,7 +3968,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) @@ -788,7 +4048,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:calendar-clock', + 'original_icon': None, 'original_name': 'Active tariff', 'platform': 'homewizard', 'previous_unique_id': None, @@ -803,7 +4063,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Device Active tariff', - 'icon': 'mdi:calendar-clock', 'options': list([ '1', '2', @@ -878,7 +4137,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l1_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l1_v', 'unit_of_measurement': , }) @@ -958,7 +4217,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l2_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l2_v', 'unit_of_measurement': , }) @@ -1038,7 +4297,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l3_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l3_v', 'unit_of_measurement': , }) @@ -1113,7 +4372,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1127,7 +4386,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -1138,6 +4396,323 @@ 'state': '12.345', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-4', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1191,7 +4766,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:counter', + 'original_icon': None, 'original_name': 'DSMR version', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1205,7 +4780,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device DSMR version', - 'icon': 'mdi:counter', }), 'context': , 'entity_id': 'sensor.device_dsmr_version', @@ -1214,6 +4788,886 @@ 'state': '50', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1267,7 +5721,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alphabetical-variant', + 'original_icon': None, 'original_name': 'Gas meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1281,7 +5735,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Gas meter identifier', - 'icon': 'mdi:alphabetical-variant', }), 'context': , 'entity_id': 'sensor.device_gas_meter_identifier', @@ -1343,7 +5796,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1357,7 +5810,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_long_power_failures_detected', @@ -1443,6 +5895,89 @@ 'state': '1111.0', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1496,7 +6031,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1510,7 +6045,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_power_failures_detected', @@ -1519,6 +6053,255 @@ 'state': '4', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '456', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '123.456', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1572,7 +6355,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alphabetical-variant', + 'original_icon': None, 'original_name': 'Smart meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1586,7 +6369,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter identifier', - 'icon': 'mdi:alphabetical-variant', }), 'context': , 'entity_id': 'sensor.device_smart_meter_identifier', @@ -1648,7 +6430,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Smart meter model', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1662,7 +6444,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter model', - 'icon': 'mdi:gauge', }), 'context': , 'entity_id': 'sensor.device_smart_meter_model', @@ -1671,6 +6452,95 @@ 'state': 'ISKRA 2M550T-101', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'aabbccddeeff_active_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Device Tariff', + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'sensor.device_tariff', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1811,7 +6681,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', 'unit_of_measurement': , }) @@ -1891,7 +6761,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t2_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', 'unit_of_measurement': , }) @@ -1971,7 +6841,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t3_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', 'unit_of_measurement': , }) @@ -2051,7 +6921,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t4_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', 'unit_of_measurement': , }) @@ -2211,7 +7081,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', 'unit_of_measurement': , }) @@ -2291,7 +7161,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t2_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', 'unit_of_measurement': , }) @@ -2371,7 +7241,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t3_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', 'unit_of_measurement': , }) @@ -2451,7 +7321,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t4_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', 'unit_of_measurement': , }) @@ -2606,7 +7476,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2621,7 +7491,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -2632,6 +7501,246 @@ 'state': '1234.567', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '230.222', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_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': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '230.333', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2685,12 +7794,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l1_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', 'unit_of_measurement': None, }) @@ -2699,7 +7808,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', @@ -2761,12 +7869,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l2_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', 'unit_of_measurement': None, }) @@ -2775,7 +7883,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', @@ -2837,12 +7944,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l3_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', 'unit_of_measurement': None, }) @@ -2851,7 +7958,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', @@ -2913,12 +8019,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l1_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', 'unit_of_measurement': None, }) @@ -2927,7 +8033,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', @@ -2989,12 +8094,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l2_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', 'unit_of_measurement': None, }) @@ -3003,7 +8108,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', @@ -3065,12 +8169,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l3_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', 'unit_of_measurement': None, }) @@ -3079,7 +8183,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', @@ -3088,6 +8191,85 @@ 'state': '6', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_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_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.345', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3141,7 +8323,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3155,7 +8337,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -3219,7 +8400,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3233,7 +8414,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -3244,6 +8424,1124 @@ 'state': '100', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter: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.gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_G001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter', + 'last_changed': , + 'last_updated': , + 'state': 'G001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': 'G001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_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.gas_meter_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': 'G001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_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.gas_meter_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': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter: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.heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_H001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'H001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': 'H001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy: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.heat_meter_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': 'H001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy: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.heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter: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.inlet_heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_IH001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'IH001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': 'IH001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none: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.inlet_heat_meter_none', + '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': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter None', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_none', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': 'IH001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy: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.inlet_heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter: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.warm_water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_WW001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Warm water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'WW001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': 'WW001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_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.warm_water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': 'WW001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water: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.warm_water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_water', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter: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.water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_W001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'W001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': 'W001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_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.water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': 'W001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water: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.water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_water', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3381,7 +9679,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l1_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l1_a', 'unit_of_measurement': , }) @@ -3461,7 +9759,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l2_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l2_a', 'unit_of_measurement': , }) @@ -3541,7 +9839,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l3_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l3_a', 'unit_of_measurement': , }) @@ -3787,7 +10085,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -3870,7 +10168,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -3953,7 +10251,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) @@ -4033,7 +10331,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l1_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l1_v', 'unit_of_measurement': , }) @@ -4113,7 +10411,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l2_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l2_v', 'unit_of_measurement': , }) @@ -4193,7 +10491,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l3_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l3_v', 'unit_of_measurement': , }) @@ -4268,7 +10566,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4282,7 +10580,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -4293,6 +10590,1203 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4346,7 +11840,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4360,7 +11854,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_long_power_failures_detected', @@ -4446,6 +11939,89 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + '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, @@ -4499,7 +12075,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4513,7 +12089,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_power_failures_detected', @@ -4522,6 +12097,255 @@ 'state': '0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4662,7 +12486,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', 'unit_of_measurement': , }) @@ -4742,7 +12566,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t2_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', 'unit_of_measurement': , }) @@ -4822,7 +12646,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t3_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', 'unit_of_measurement': , }) @@ -4902,7 +12726,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t4_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', 'unit_of_measurement': , }) @@ -5062,7 +12886,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', 'unit_of_measurement': , }) @@ -5142,7 +12966,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t2_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', 'unit_of_measurement': , }) @@ -5222,7 +13046,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t3_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', 'unit_of_measurement': , }) @@ -5302,7 +13126,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t4_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', 'unit_of_measurement': , }) @@ -5457,7 +13281,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5472,7 +13296,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -5483,6 +13306,246 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_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': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + '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, @@ -5536,12 +13599,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l1_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', 'unit_of_measurement': None, }) @@ -5550,7 +13613,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', @@ -5612,12 +13674,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l2_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', 'unit_of_measurement': None, }) @@ -5626,7 +13688,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', @@ -5688,12 +13749,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l3_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', 'unit_of_measurement': None, }) @@ -5702,7 +13763,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', @@ -5764,12 +13824,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l1_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', 'unit_of_measurement': None, }) @@ -5778,7 +13838,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', @@ -5840,12 +13899,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l2_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', 'unit_of_measurement': None, }) @@ -5854,7 +13913,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', @@ -5916,12 +13974,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l3_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', 'unit_of_measurement': None, }) @@ -5930,7 +13988,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', @@ -5939,6 +13996,85 @@ 'state': '0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_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_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_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '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_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6085,7 +14221,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -6105,6 +14241,332 @@ 'state': '1457.277', }) # --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_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_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_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_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '63.651', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_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_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '1457.277', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_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_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_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, @@ -6318,7 +14780,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6332,7 +14794,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -6396,7 +14857,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6410,7 +14871,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -6476,7 +14936,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6490,7 +14950,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -6556,7 +15015,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6571,7 +15030,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -6582,6 +15040,85 @@ 'state': '17.014', }) # --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_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_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_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '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_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6635,7 +15172,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6649,7 +15186,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -6713,7 +15249,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6727,7 +15263,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -6738,6 +15273,246 @@ 'state': '84', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_apparent_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_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_current: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_current: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', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_a', + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][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': '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_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[SDM230-entity_ids4][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6821,6 +15596,86 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_factor: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_factor: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_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor', + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6884,7 +15739,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -6904,6 +15759,893 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_reactive_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_reactive_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_power', + 'entity_id': 'sensor.device_active_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_voltage: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_voltage: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', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_apparent_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_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_current: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_current: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_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '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_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_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': '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_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_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_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_factor: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_power_factor: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_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_reactive_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_reactive_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_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7064,6 +16806,86 @@ 'state': '2.705', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_voltage: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_voltage: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_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7117,7 +16939,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7131,7 +16953,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -7195,7 +17016,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7209,7 +17030,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -7220,6 +17040,726 @@ 'state': '92', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_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_apparent_power_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 apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_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_apparent_power_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 apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_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_apparent_power_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 apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current: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', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_a', + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': '15.521', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': '15.477', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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[SDM630-entity_ids5][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': '49.926', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7303,6 +17843,246 @@ 'state': '-900.194', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_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_factor_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 power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_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_factor_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 power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_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_factor_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 power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7366,7 +18146,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -7449,7 +18229,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -7532,7 +18312,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) @@ -7552,6 +18332,2341 @@ 'state': '0.0', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_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_power', + 'entity_id': 'sensor.device_active_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_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_reactive_power_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 reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_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_reactive_power_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 reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_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_reactive_power_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 reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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_power_phase_1', + '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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': '228.391', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_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_power_phase_2', + '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': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][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': '229.612', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_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_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_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_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_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_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_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_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current: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_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '15.521', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '15.477', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '49.926', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_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_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_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_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_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_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_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_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_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_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_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_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_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_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7712,6 +20827,246 @@ 'state': '0.101', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '228.391', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_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_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': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '229.612', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7765,7 +21120,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7779,7 +21134,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -7843,7 +21197,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7857,7 +21211,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -7868,3 +21221,889 @@ 'state': '92', }) # --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter: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.gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_G001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter', + 'last_changed': , + 'last_updated': , + 'state': 'G001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'unknown_unit', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_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.gas_meter_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_unknown_unit_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_2', + 'last_changed': , + 'last_updated': , + 'state': 'unknown_unit', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_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.gas_meter_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': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'unknown_unit', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_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.gas_meter_total_gas_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'homewizard_unknown_unit', + 'unit_of_measurement': 'cats', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': 'cats', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas_2', + 'last_changed': , + 'last_updated': , + 'state': '666.666', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter: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.heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_H001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'H001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy: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.heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter: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.inlet_heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_IH001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'IH001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy: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.inlet_heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter: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.warm_water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_WW001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Warm water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'WW001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_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.warm_water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter: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.water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_W001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'W001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_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.water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 0fb4680a0b1..c8591b1f1d9 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,4 +1,154 @@ # serializer version: 1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-KWH1-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': None, + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-KWH3-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': None, + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- # name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -79,7 +229,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -109,7 +258,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, @@ -155,7 +304,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', - 'icon': 'mdi:lock-open', }), 'context': , 'entity_id': 'switch.device_switch_lock', @@ -185,7 +333,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:lock-open', + 'original_icon': None, 'original_name': 'Switch lock', 'platform': 'homewizard', 'previous_unique_id': None, @@ -231,7 +379,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -261,7 +408,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, @@ -307,7 +454,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -337,7 +483,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index c25a4ed0f4e..b73a194c5ae 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,9 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize( + "device_fixture", ["HWE-WTR", "SDM230", "SDM630", "HWE-KWH1", "HWE-KWH3"] +) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 5a140fa70c8..8356c94d164 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -18,6 +18,8 @@ from tests.typing import ClientSessionGenerator "HWE-WTR", "SDM230", "SDM630", + "HWE-KWH1", + "HWE-KWH3", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index a54f98899c6..a7fb2834bd3 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -97,7 +97,9 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize( + "device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630", "HWE-KWH1", "HWE-KWH3"] +) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: """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 7e59769a768..243e8f542e2 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,16 +3,18 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import Data import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.homewizard import DOMAIN from homeassistant.components.homewizard.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = [ pytest.mark.usefixtures("init_integration"), @@ -26,130 +28,203 @@ pytestmark = [ ( "HWE-P1", [ + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", "sensor.device_dsmr_version", - "sensor.device_smart_meter_model", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", "sensor.device_smart_meter_identifier", - "sensor.device_wi_fi_ssid", - "sensor.device_active_tariff", - "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", - "sensor.device_power_failures_detected", - "sensor.device_long_power_failures_detected", - "sensor.device_active_average_demand", - "sensor.device_peak_demand_current_month", - "sensor.device_total_gas", - "sensor.device_gas_meter_identifier", - "sensor.device_active_water_usage", - "sensor.device_total_water_usage", + "sensor.device_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.gas_meter_gas", + "sensor.heat_meter_energy", + "sensor.inlet_heat_meter_none", + "sensor.warm_water_meter_water", + "sensor.water_meter_water", ], ), ( "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_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "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", + "sensor.device_water_usage", ], ), ( "HWE-SKT", [ + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_power_phase_1", + "sensor.device_power", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_export", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", ], ), ( "HWE-WTR", [ + "sensor.device_total_water_usage", + "sensor.device_water_usage", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_active_water_usage", - "sensor.device_total_water_usage", ], ), ( "SDM230", [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_power", + "sensor.device_reactive_power", + "sensor.device_voltage", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_export", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", ], ), ( "SDM630", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_power", + "sensor.device_reactive_power", + "sensor.device_voltage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "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", ], ), ], @@ -180,24 +255,24 @@ async def test_sensors( ( "HWE-P1", [ + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_wi_fi_strength", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", ], ), ( "HWE-P1-unused-exports", [ - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", ], ), ( @@ -215,12 +290,74 @@ async def test_sensors( ( "SDM230", [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_reactive_power", + "sensor.device_voltage", "sensor.device_wi_fi_strength", ], ), ( "SDM630", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_reactive_power", + "sensor.device_voltage", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_wi_fi_strength", ], ), @@ -245,7 +382,7 @@ async def test_sensors_unreachable( exception: Exception, ) -> None: """Test sensor handles API unreachable.""" - assert (state := hass.states.get("sensor.device_total_energy_import_tariff_1")) + assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" mock_homewizardenergy.data.side_effect = exception @@ -256,167 +393,299 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +async def test_external_sensors_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test external device sensor handles API unreachable.""" + assert (state := hass.states.get("sensor.gas_meter_gas")) + assert state.state == "111.111" + + mock_homewizardenergy.data.return_value = Data.from_dict({}) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE + + @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_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", "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_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "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_voltage", + "sensor.device_water_usage", ], ), ( "HWE-WTR", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", "sensor.device_dsmr_version", - "sensor.device_smart_meter_model", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", - "sensor.device_active_tariff", - "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", - "sensor.device_power_failures_detected", - "sensor.device_long_power_failures_detected", - "sensor.device_active_average_demand", - "sensor.device_peak_demand_current_month", - "sensor.device_total_gas", - "sensor.device_gas_meter_identifier", + "sensor.device_voltage", ], ), ( "SDM230", [ - "sensor.device_active_average_demand", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_tariff", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_water_usage", + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_average_demand", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", "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_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "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_water_usage", ], ), ( "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_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", "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_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "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_voltage", + "sensor.device_water_usage", + ], + ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_average_demand", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "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_water_usage", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "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_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "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_voltage", + "sensor.device_water_usage", ], ), ], @@ -428,3 +697,49 @@ async def test_entities_not_created_for_device( """Ensures entities for a specific device are not created.""" for entity_id in entity_ids: assert not hass.states.get(entity_id) + + +async def test_gas_meter_migrated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test old gas meter sensor is migrated.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + "aabbccddeeff_total_gas_m3", + ) + + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.homewizard_aabbccddeeff_total_gas_m3" + + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + # Make really sure this happens + assert entity_entry.previous_unique_id == "aabbccddeeff_total_gas_m3" + + +async def test_gas_unique_id_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test old gas meter id sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + "aabbccddeeff_gas_unique_id", + ) + + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.homewizard_aabbccddeeff_gas_unique_id" + + assert not entity_registry.async_get(entity_id) diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 61ca34fab7a..bfc23264340 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -58,6 +58,20 @@ pytestmark = [ "switch.device_switch_lock", ], ), + ( + "HWE-KWH1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), + ( + "HWE-KWH3", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ], ) async def test_entities_not_created_for_device( @@ -77,6 +91,8 @@ async def test_entities_not_created_for_device( ("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"), + ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), ], ) async def test_switch_entities( diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 876050586d2..5c5b6c0a44a 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -39,6 +39,15 @@ def config_data(): } +@pytest.fixture +def another_config_data(): + """Provide configuration data for tests.""" + return { + CONF_USERNAME: "user2", + CONF_PASSWORD: "fake2", + } + + @pytest.fixture def config_options(): """Provide configuratio options for test.""" @@ -55,6 +64,16 @@ def config_entry(config_data, config_options): ) +@pytest.fixture +def config_entry2(another_config_data, config_options): + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=another_config_data, + options=config_options, + ) + + @pytest.fixture def device(): """Mock a somecomfort.Device.""" diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index 4f7d8fe1308..d1faf9af9a0 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -24,13 +24,13 @@ 'min_humidity': 30, 'min_temp': -13.9, 'permanent_hold': False, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ 'none', 'away', - 'Hold', + 'hold', ]), - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': None, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 9c73e88c3df..743689da43d 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL +from homeassistant.components.honeywell.climate import PRESET_HOLD, RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -46,7 +46,6 @@ from . import init_integration, reset_mock from tests.common import async_fire_time_changed FAN_ACTION = "fan_action" -PRESET_HOLD = "Hold" async def test_no_thermostat_options( diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ccfc2c5d264..98578217af6 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -33,6 +33,22 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - ) # 1 climate entity; 2 sensor entities +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) +async def test_setup_multiple_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, config_entry2: MockConfigEntry +) -> None: + """Initialize the config entry.""" + 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 + + config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry2.entry_id) + await hass.async_block_till_done() + assert config_entry2.state is ConfigEntryState.LOADED + + async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 2f1259c22de..ab56dca5580 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -211,7 +211,7 @@ async def test_auth_active_access_with_access_token_in_header( token = hass_access_token await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.OK @@ -231,7 +231,7 @@ async def test_auth_active_access_with_access_token_in_header( req = await client.get("/", headers={"Authorization": f"BEARER {token}"}) assert req.status == HTTPStatus.UNAUTHORIZED - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.UNAUTHORIZED @@ -297,7 +297,7 @@ async def test_auth_access_signed_path_with_refresh_token( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -325,7 +325,7 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED # refresh token gone should also invalidate signature - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) req = await client.get(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED @@ -342,7 +342,7 @@ async def test_auth_access_signed_path_with_query_param( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -372,7 +372,7 @@ async def test_auth_access_signed_path_with_query_param_order( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, @@ -413,7 +413,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, @@ -452,7 +452,7 @@ async def test_auth_access_signed_path_with_query_param_tamper( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -491,9 +491,7 @@ async def test_auth_access_signed_path_via_websocket( assert msg["id"] == 5 assert msg["success"] - refresh_token = await hass.auth.async_validate_access_token( - hass_read_only_access_token - ) + refresh_token = hass.auth.async_validate_access_token(hass_read_only_access_token) signature = yarl.URL(msg["result"]["path"]).query["authSig"] claims = jwt.decode( signature, @@ -523,7 +521,7 @@ async def test_auth_access_signed_path_with_http( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get( "/hello", headers={"Authorization": f"Bearer {hass_access_token}"} @@ -567,7 +565,7 @@ async def test_local_only_user_rejected( await async_setup_auth(hass, app) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.OK diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 9e4353d7e61..0cd85b48b06 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -1,4 +1,5 @@ """Test security filter middleware.""" +import asyncio from http import HTTPStatus from aiohttp import web @@ -75,7 +76,6 @@ async def test_bad_requests( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - event_loop, ) -> None: """Test request paths that should be filtered.""" app = web.Application() @@ -93,7 +93,7 @@ async def test_bad_requests( man_params = "" http = urllib3.PoolManager() - resp = await event_loop.run_in_executor( + resp = await asyncio.get_running_loop().run_in_executor( None, http.request, "GET", @@ -126,7 +126,6 @@ async def test_bad_requests_with_unsafe_bytes( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - event_loop, ) -> None: """Test request with unsafe bytes in their URLs.""" app = web.Application() @@ -144,7 +143,7 @@ async def test_bad_requests_with_unsafe_bytes( man_params = "" http = urllib3.PoolManager() - resp = await event_loop.run_in_executor( + resp = await asyncio.get_running_loop().run_in_executor( None, http.request, "GET", diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py index 1d711464966..b11d54defd6 100644 --- a/tests/components/http/test_static.py +++ b/tests/components/http/test_static.py @@ -58,4 +58,4 @@ async def test_static_path_blocks_anchors( # it gets here but we want to make sure if aiohttp ever # changes we still block it. with pytest.raises(HTTPForbidden): - _get_file_path(canonical_url, tmp_path, False) + _get_file_path(canonical_url, tmp_path) diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 3f0bdae8e53..e74ff04e035 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -289,7 +289,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_today.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -299,7 +302,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_week.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -309,7 +315,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_month.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -319,7 +328,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_year.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS diff --git a/tests/components/hunterdouglas_powerview/__init__.py b/tests/components/hunterdouglas_powerview/__init__.py index 034d845b110..1cab5f9071e 100644 --- a/tests/components/hunterdouglas_powerview/__init__.py +++ b/tests/components/hunterdouglas_powerview/__init__.py @@ -1 +1,3 @@ """Tests for the Hunter Douglas PowerView integration.""" + +MOCK_MAC = "AA::BB::CC::DD::EE::FF" diff --git a/tests/components/hunterdouglas_powerview/conftest.py b/tests/components/hunterdouglas_powerview/conftest.py new file mode 100644 index 00000000000..e4e56abd56c --- /dev/null +++ b/tests/components/hunterdouglas_powerview/conftest.py @@ -0,0 +1,47 @@ +"""Tests for the Hunter Douglas PowerView integration.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(scope="session") +def powerview_userdata(): + """Return the userdata fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/userdata.json")) + + +@pytest.fixture(scope="session") +def powerview_fwversion(): + """Return the fwversion fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/fwversion.json")) + + +@pytest.fixture(scope="session") +def powerview_scenes(): + """Return the scenes fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/scenes.json")) + + +@pytest.fixture +def mock_powerview_v2_hub(powerview_userdata, powerview_fwversion, powerview_scenes): + """Mock a Powerview v2 hub.""" + with patch( + "homeassistant.components.hunterdouglas_powerview.UserData.get_resources", + return_value=powerview_userdata, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Rooms.get_resources", + return_value={"roomData": []}, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Scenes.get_resources", + return_value=powerview_scenes, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Shades.get_resources", + return_value={"shadeData": []}, + ), patch( + "homeassistant.components.hunterdouglas_powerview.ApiEntryPoint", + return_value=powerview_fwversion, + ): + yield diff --git a/tests/components/hunterdouglas_powerview/fixtures/scenes.json b/tests/components/hunterdouglas_powerview/fixtures/scenes.json new file mode 100644 index 00000000000..7a9f7d9e8eb --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/scenes.json @@ -0,0 +1,25 @@ +{ + "sceneIds": [46274, 21015], + "sceneData": [ + { + "roomId": 12538, + "name": "one", + "colorId": 12, + "iconId": 0, + "networkNumber": 250, + "id": 46274, + "order": 0, + "hkAssist": false + }, + { + "roomId": 12538, + "name": "two", + "colorId": 14, + "iconId": 0, + "networkNumber": 231, + "id": 21015, + "order": 1, + "hkAssist": false + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index f39b4c1f68e..0511e7bf821 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the Logitech Harmony Hub config flow.""" +"""Test the Hunter Douglas Powerview config flow.""" import asyncio from ipaddress import ip_address import json @@ -11,6 +11,8 @@ from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.core import HomeAssistant +from . import MOCK_MAC + from tests.common import MockConfigEntry, load_fixture ZEROCONF_HOST = "1.2.3.4" @@ -20,7 +22,7 @@ HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( hostname="mock_hostname", name="Hunter Douglas Powerview Hub._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA::BB::CC::DD::EE::FF"}, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) diff --git a/tests/components/hunterdouglas_powerview/test_scene.py b/tests/components/hunterdouglas_powerview/test_scene.py new file mode 100644 index 00000000000..b4dd4491a72 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/test_scene.py @@ -0,0 +1,36 @@ +"""Test the Hunter Douglas Powerview scene platform.""" +from unittest.mock import patch + +from homeassistant.components.hunterdouglas_powerview.const import DOMAIN +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None: + """Test the scenes.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("scene.alexanderhd_one").state == STATE_UNKNOWN + assert hass.states.get("scene.alexanderhd_two").state == STATE_UNKNOWN + + with patch( + "homeassistant.components.hunterdouglas_powerview.scene.PvScene.activate" + ) as mock_activate: + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "scene.alexanderhd_one"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_activate.assert_called_once() diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py new file mode 100644 index 00000000000..443cbd52c36 --- /dev/null +++ b/tests/components/huum/__init__.py @@ -0,0 +1 @@ +"""Tests for the huum integration.""" diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py new file mode 100644 index 00000000000..7163521b446 --- /dev/null +++ b/tests/components/huum/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the huum config flow.""" +from unittest.mock import patch + +from huum.exceptions import Forbidden +import pytest + +from homeassistant import config_entries +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USERNAME = "test-username" +TEST_PASSWORD = "test-password" + + +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"] == {} + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_USERNAME + assert result2["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: + """Test that we handle already existing entities with same id.""" + mock_config_entry = MockConfigEntry( + title="Huum Sauna", + domain=DOMAIN, + unique_id=TEST_USERNAME, + data={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == FlowResultType.ABORT + + +@pytest.mark.parametrize( + ( + "raises", + "error_base", + ), + [ + (Exception, "unknown"), + (Forbidden, "invalid_auth"), + ], +) +async def test_huum_errors( + hass: HomeAssistant, raises: Exception, error_base: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + side_effect=raises, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error_base} + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/ibeacon/test_config_flow.py b/tests/components/ibeacon/test_config_flow.py index 7dee1b5c709..2f79474dea7 100644 --- a/tests/components/ibeacon/test_config_flow.py +++ b/tests/components/ibeacon/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.ibeacon.const import DOMAIN +from homeassistant.components.ibeacon.const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -49,3 +49,70 @@ async def test_setup_user_already_setup( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + 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"] == FlowResultType.FORM + assert result["step_id"] == "init" + + # test save invalid uuid + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "new_uuid": "invalid", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"new_uuid": "invalid_uuid_format"} + + # test save new uuid + uuid = "daa4b6bb-b77a-4662-aeb8-b3ed56454091" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "new_uuid": uuid, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: [uuid]} + + # test save duplicate uuid + 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_ALLOW_NAMELESS_UUIDS: [uuid], + "new_uuid": uuid, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: [uuid]} + + # delete + 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_ALLOW_NAMELESS_UUIDS: [], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: []} diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 3c9beaf396d..372907307a7 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -4,7 +4,15 @@ import time import pytest -from homeassistant.components.ibeacon.const import ATTR_SOURCE, DOMAIN, UPDATE_INTERVAL +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.components.ibeacon.const import ( + ATTR_SOURCE, + CONF_ALLOW_NAMELESS_UUIDS, + DOMAIN, + UPDATE_INTERVAL, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo @@ -26,6 +34,7 @@ from tests.components.bluetooth import ( inject_advertisement_with_time_and_source_connectable, inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -146,6 +155,106 @@ async def test_ignore_default_name(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids()) == before_entity_count +async def test_default_name_allowlisted(hass: HomeAssistant) -> None: + """Test we do NOT ignore beacons with default device name but allowlisted UUID.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={CONF_ALLOW_NAMELESS_UUIDS: ["426c7565-4368-6172-6d42-6561636f6e73"]}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) > before_entity_count + + +async def test_default_name_allowlisted_restore(hass: HomeAssistant) -> None: + """Test that ignored nameless iBeacons are restored when allowlist entry is added.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"new_uuid": "426c7565-4368-6172-6d42-6561636f6e73"}, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) > before_entity_count + + +async def test_default_name_allowlisted_restore_late(hass: HomeAssistant) -> None: + """Test that allowlisting an ignored but no longer advertised nameless iBeacon has no effect.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + # Fastforward time until the device is no longer advertised + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch_bluetooth_time( + monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"new_uuid": "426c7565-4368-6172-6d42-6561636f6e73"}, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + async def test_rotating_major_minor_and_mac_with_name(hass: HomeAssistant) -> None: """Test the different uuid, major, minor from many addresses removes all associated entities.""" entry = MockConfigEntry( diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index d36cffbce06..0561085823d 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -239,6 +239,7 @@ async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -464,7 +465,7 @@ async def test_advanced_options_form( # Check if entry was updated for key, value in new_config.items(): assert entry.data[key] == value - except vol.MultipleInvalid: + except vol.Invalid: # Check if form was expected with these options assert assert_result == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index a00f9d9c25d..8a8ac88c8aa 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -8,6 +8,7 @@ from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response import pytest from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.const import CONF_CHARSET from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder from homeassistant.components.sensor.const import SensorStateClass from homeassistant.const import STATE_UNAVAILABLE @@ -131,13 +132,16 @@ async def test_entry_startup_fails( ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize("charset", ["utf-8", "us-ascii"], ids=["utf-8", "us-ascii"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool, charset: str ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config = MOCK_CONFIG.copy() + config[CONF_CHARSET] = charset + config_entry = MockConfigEntry(domain=DOMAIN, data=config) 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/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index e333071b0bd..d5e5e0c33ee 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -265,10 +265,7 @@ async def _test_common_success( assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("description_placeholders") == placeholders @@ -321,10 +318,7 @@ async def _test_common_success_w_authorize( assert result["progress_action"] == "authorize" assert result["step_id"] == "authorize" mock_subscribe_state_updates.assert_awaited_once() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision" + await hass.async_block_till_done() with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", @@ -337,10 +331,7 @@ async def _test_common_success_w_authorize( assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["description_placeholders"] == {"url": "http://blabla.local"} @@ -578,10 +569,7 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> None: assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() return result["flow_id"] diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 4caf914ca19..65d7fb93d19 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -133,7 +133,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": None, "b2": None}}) @@ -153,7 +153,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("input_boolean.b1", "on"), State("input_boolean.b2", "off")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index 9233668c113..2f9b677e134 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -96,7 +96,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: (State("input_button.b1", "2021-01-01T23:59:59+00:00"),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": None, "b2": None}}) diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index a0b80ac420c..0a3f9b3ed6c 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -325,7 +325,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) initial = datetime.datetime(2017, 1, 1, 23, 42) default = datetime.datetime.combine(datetime.date.today(), DEFAULT_TIME) diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 1334ba4aebd..305ff74b6bf 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -238,7 +238,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: hass, (State("input_number.b1", "70"), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -261,7 +261,7 @@ async def test_restore_invalid_state(hass: HomeAssistant) -> None: hass, (State("input_number.b1", "="), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -284,7 +284,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("input_number.b1", "70"), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -308,7 +308,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": {"min": 0, "max": 100}}}) diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 23d1c3307e5..c057407a644 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -165,7 +165,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: (State("input_text.b1", "test"), State("input_text.b2", "testing too long")), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"b1": None, "b2": {"min": 0, "max": 10}}} @@ -187,7 +187,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non (State("input_text.b1", "testing"), State("input_text.b2", "testing too long")), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -211,7 +211,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": {"min": 0, "max": 100}}}) diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index fe344332f38..4a5bfb007f0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -676,6 +676,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert mock_setup_entry.called assert result4["type"] == "abort" diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 733cb795271..e1377d81100 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -27,7 +27,7 @@ async def test_watching( assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER" - assert state.attributes.get(ATTR_ICON) == "mdi:television-play" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching" assert state.state == "3" diff --git a/tests/components/justnimbus/conftest.py b/tests/components/justnimbus/conftest.py new file mode 100644 index 00000000000..c67f9470a1f --- /dev/null +++ b/tests/components/justnimbus/conftest.py @@ -0,0 +1,8 @@ +"""Reusable fixtures for justnimbus tests.""" + +from homeassistant.components.justnimbus.const import CONF_ZIP_CODE +from homeassistant.const import CONF_CLIENT_ID + +FIXTURE_OLD_USER_INPUT = {CONF_CLIENT_ID: "test_id"} +FIXTURE_USER_INPUT = {CONF_CLIENT_ID: "test_id", CONF_ZIP_CODE: "test_zip"} +FIXTURE_UNIQUE_ID = "test_idtest_zip" diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index 2c8d41929df..8db8dd09b23 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -4,12 +4,13 @@ from unittest.mock import patch from justnimbus.exceptions import InvalidClientID, JustNimbusError import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.justnimbus.const import DOMAIN -from homeassistant.const import CONF_CLIENT_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import FIXTURE_OLD_USER_INPUT, FIXTURE_UNIQUE_ID, FIXTURE_USER_INPUT + from tests.common import MockConfigEntry @@ -57,9 +58,7 @@ async def test_form_errors( ): result2 = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) assert result2["type"] == FlowResultType.FORM @@ -73,8 +72,8 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, title="JustNimbus", - data={CONF_CLIENT_ID: "test_id"}, - unique_id="test_id", + data=FIXTURE_USER_INPUT, + unique_id=FIXTURE_UNIQUE_ID, ) entry.add_to_hass(hass) @@ -86,9 +85,7 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) assert result2.get("type") == FlowResultType.ABORT @@ -103,15 +100,49 @@ async def _set_up_justnimbus(hass: HomeAssistant, flow_id: str) -> None: ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( flow_id=flow_id, - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "JustNimbus" - assert result2["data"] == { - CONF_CLIENT_ID: "test_id", - } + assert result2["data"] == FIXTURE_USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + with patch( + "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", + return_value=False, + ): + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=FIXTURE_UNIQUE_ID, data=FIXTURE_OLD_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config.entry_id, + }, + data=FIXTURE_OLD_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config.data == FIXTURE_USER_INPUT diff --git a/tests/components/justnimbus/test_init.py b/tests/components/justnimbus/test_init.py new file mode 100644 index 00000000000..223e36d2bbc --- /dev/null +++ b/tests/components/justnimbus/test_init.py @@ -0,0 +1,21 @@ +"""Tests for JustNimbus initialization.""" +from homeassistant.components.justnimbus.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import FIXTURE_OLD_USER_INPUT, FIXTURE_UNIQUE_ID + +from tests.common import MockConfigEntry + + +async def test_config_entry_reauth_at_setup(hass: HomeAssistant) -> None: + """Test that setting up with old config results in reauth.""" + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=FIXTURE_UNIQUE_ID, data=FIXTURE_OLD_USER_INPUT + ) + mock_config.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert mock_config.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config.async_get_active_flows(hass, {"reauth"})) diff --git a/tests/components/jvc_projector/test_binary_sensor.py b/tests/components/jvc_projector/test_binary_sensor.py new file mode 100644 index 00000000000..b327538991c --- /dev/null +++ b/tests/components/jvc_projector/test_binary_sensor.py @@ -0,0 +1,22 @@ +"""Tests for the JVC Projector binary sensor device.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "binary_sensor.jvc_projector_power" + + +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 entity_registry.async_get(entity.entity_id) diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 5505e160ca7..28bf835e032 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -61,6 +61,14 @@ async def test_commands( ) assert mock_device.remote.call_count == 1 + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["hdmi_1"]}, + blocking=True, + ) + assert mock_device.remote.call_count == 2 + async def test_unknown_command( hass: HomeAssistant, diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 625aa7926fe..e157c3e5d0a 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_import(hass: HomeAssistant) -> None: @@ -46,3 +47,20 @@ async def test_import_once(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth works.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["handler"] == DOMAIN + assert flows[0]["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 71f3a83c701..c65d53478d2 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -102,6 +102,7 @@ async def test_demo_statistics_growth( assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.freeze_time("2023-10-21") async def test_issues_created( mock_history, hass: HomeAssistant, @@ -125,7 +126,7 @@ async def test_issues_created( "issues": [ { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -139,7 +140,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -153,7 +154,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -167,7 +168,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -181,7 +182,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "is_fixable": True, @@ -193,6 +194,20 @@ async def test_issues_created( "translation_placeholders": None, "ignored": False, }, + { + "breaks_in_ha_version": None, + "created": "2023-10-21T00:00:00+00:00", + "dismissed_version": None, + "domain": "homeassistant", + "is_fixable": False, + "issue_domain": DOMAIN, + "issue_id": ANY, + "learn_more_url": None, + "severity": "error", + "translation_key": "config_entry_reauth", + "translation_placeholders": None, + "ignored": False, + }, ] } @@ -242,7 +257,7 @@ async def test_issues_created( "issues": [ { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -256,7 +271,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -270,7 +285,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -284,7 +299,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "is_fixable": True, @@ -296,5 +311,19 @@ async def test_issues_created( "translation_placeholders": None, "ignored": False, }, + { + "breaks_in_ha_version": None, + "created": "2023-10-21T00:00:00+00:00", + "dismissed_version": None, + "domain": "homeassistant", + "is_fixable": False, + "issue_domain": DOMAIN, + "issue_id": ANY, + "learn_more_url": None, + "severity": "error", + "translation_key": "config_entry_reauth", + "translation_placeholders": None, + "ignored": False, + }, ] } diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py index 57a695c5919..cb72aba2704 100644 --- a/tests/components/kmtronic/test_switch.py +++ b/tests/components/kmtronic/test_switch.py @@ -39,15 +39,18 @@ async def test_relay_on_off( text="", ) - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_on", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" # Mocks the response for turning a relay1 off @@ -57,11 +60,14 @@ async def test_relay_on_off( ) await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_off", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" # Mocks the response for turning a relay1 on @@ -71,11 +77,14 @@ async def test_relay_on_off( ) await hass.services.async_call( - "switch", "toggle", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "toggle", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" @@ -95,7 +104,7 @@ async def test_update(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" aioclient_mock.clear_requests() @@ -106,7 +115,7 @@ async def test_update(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" @@ -128,7 +137,7 @@ async def test_failed_update( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" aioclient_mock.clear_requests() @@ -140,7 +149,7 @@ async def test_failed_update( async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == STATE_UNAVAILABLE future += timedelta(minutes=10) @@ -152,7 +161,7 @@ async def test_failed_update( async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == STATE_UNAVAILABLE @@ -180,15 +189,18 @@ async def test_relay_on_off_reversed( text="", ) - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_off", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" # Mocks the response for turning a relay1 off @@ -198,9 +210,12 @@ async def test_relay_on_off_reversed( ) await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_on", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index 5796eae8393..30b297218cc 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -7,6 +7,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.components.knx import async_unload_entry as knx_async_unload_entry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import KNXTestKit @@ -274,3 +275,18 @@ async def test_reload_service( ) mock_unload_entry.assert_called_once() mock_setup_entry.assert_called_once() + + +async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test service setup failed.""" + await knx.setup_integration({}) + await knx.mock_config_entry.async_unload(hass) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + "knx", + "send", + {"address": "1/2/3", "payload": True, "response": False}, + blocking=True, + ) + assert str(exc_info.value) == "KNX entry not loaded" diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 3ba351a4225..791b70c1283 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -162,7 +162,6 @@ async def test_sensors_available_after_restart( manufacturer="Kraken.com", entry_type=dr.DeviceEntryType.SERVICE, ) - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..bac7d4b3c61 --- /dev/null +++ b/tests/components/lamarzocco/__init__.py @@ -0,0 +1,25 @@ +"""Mock inputs for tests.""" + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +HOST_SELECTION = { + CONF_HOST: "192.168.1.1", +} + +PASSWORD_SELECTION = { + CONF_PASSWORD: "password", +} + +USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} + + +async def async_init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the La Marzocco integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py new file mode 100644 index 00000000000..7bb7e849ef1 --- /dev/null +++ b/tests/components/lamarzocco/conftest.py @@ -0,0 +1,127 @@ +"""Lamarzocco session fixtures.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from lmcloud.const import LaMarzoccoModel +import pytest + +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import USER_INPUT, async_init_integration + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + data=USER_INPUT + | {CONF_MACHINE: mock_lamarzocco.serial_number, CONF_HOST: "host"}, + unique_id=mock_lamarzocco.serial_number, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock +) -> MockConfigEntry: + """Set up the LaMetric integration for testing.""" + await async_init_integration(hass, mock_config_entry) + + return mock_config_entry + + +@pytest.fixture +def device_fixture() -> LaMarzoccoModel: + """Return the device fixture for a specific device.""" + return LaMarzoccoModel.GS3_AV + + +@pytest.fixture +def mock_lamarzocco( + request: pytest.FixtureRequest, device_fixture: LaMarzoccoModel +) -> Generator[MagicMock, None, None]: + """Return a mocked LM client.""" + model_name = device_fixture + + if model_name == LaMarzoccoModel.GS3_AV: + serial_number = "GS01234" + true_model_name = "GS3 AV" + elif model_name == LaMarzoccoModel.GS3_MP: + serial_number = "GS01234" + true_model_name = "GS3 MP" + elif model_name == LaMarzoccoModel.LINEA_MICRA: + serial_number = "MR01234" + true_model_name = "Linea Micra" + elif model_name == LaMarzoccoModel.LINEA_MINI: + serial_number = "LM01234" + true_model_name = "Linea Mini" + + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient", + autospec=True, + ) as lamarzocco_mock, patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient", + new=lamarzocco_mock, + ): + lamarzocco = lamarzocco_mock.return_value + + lamarzocco.machine_info = { + "machine_name": serial_number, + "serial_number": serial_number, + } + + lamarzocco.model_name = model_name + lamarzocco.true_model_name = true_model_name + lamarzocco.machine_name = serial_number + lamarzocco.serial_number = serial_number + + lamarzocco.firmware_version = "1.1" + lamarzocco.latest_firmware_version = "1.2" + lamarzocco.gateway_version = "v2.2-rc0" + lamarzocco.latest_gateway_version = "v3.1-rc4" + lamarzocco.update_firmware.return_value = True + + lamarzocco.current_status = load_json_object_fixture( + "current_status.json", DOMAIN + ) + lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) + lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN) + + lamarzocco.get_all_machines.return_value = [ + (serial_number, model_name), + ] + lamarzocco.check_local_connection.return_value = True + lamarzocco.initialized = False + lamarzocco.websocket_connected = True + + async def websocket_connect_mock( + callback: MagicMock, use_sigterm_handler: MagicMock + ) -> None: + """Mock the websocket connect method.""" + return None + + lamarzocco.lm_local_api.websocket_connect = websocket_connect_mock + + yield lamarzocco + + +@pytest.fixture +def remove_local_connection( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Remove the local connection.""" + data = mock_config_entry.data.copy() + del data[CONF_HOST] + hass.config_entries.async_update_entry(mock_config_entry, data=data) + return mock_config_entry diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json new file mode 100644 index 00000000000..60d11b0d470 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config.json @@ -0,0 +1,187 @@ +{ + "version": "v1", + "preinfusionModesAvailable": ["ByDoseType"], + "machineCapabilities": [ + { + "family": "GS3AV", + "groupsNumber": 1, + "coffeeBoilersNumber": 1, + "hasCupWarmer": false, + "steamBoilersNumber": 1, + "teaDosesNumber": 1, + "machineModes": ["BrewingMode", "StandBy"], + "schedulingType": "weeklyScheduling" + } + ], + "machine_sn": "GS01234", + "machine_hw": "2", + "isPlumbedIn": true, + "isBackFlushEnabled": false, + "standByTime": 0, + "tankStatus": true, + "groupCapabilities": [ + { + "capabilities": { + "groupType": "AV_Group", + "groupNumber": "Group1", + "boilerId": "CoffeeBoiler1", + "hasScale": false, + "hasFlowmeter": true, + "numberOfDoses": 4 + }, + "doses": [ + { + "groupNumber": "Group1", + "doseIndex": "DoseA", + "doseType": "PulsesType", + "stopTarget": 135 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseB", + "doseType": "PulsesType", + "stopTarget": 97 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseC", + "doseType": "PulsesType", + "stopTarget": 108 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseD", + "doseType": "PulsesType", + "stopTarget": 121 + } + ], + "doseMode": { + "groupNumber": "Group1", + "brewingType": "PulsesType" + } + } + ], + "machineMode": "BrewingMode", + "teaDoses": { + "DoseA": { + "doseIndex": "DoseA", + "stopTarget": 8 + } + }, + "boilers": [ + { + "id": "SteamBoiler", + "isEnabled": true, + "target": 123.90000152587891, + "current": 123.80000305175781 + }, + { + "id": "CoffeeBoiler1", + "isEnabled": true, + "target": 95, + "current": 96.5 + } + ], + "boilerTargetTemperature": { + "SteamBoiler": 123.90000152587891, + "CoffeeBoiler1": 95 + }, + "preinfusionMode": { + "Group1": { + "groupNumber": "Group1", + "preinfusionStyle": "PreinfusionByDoseType" + } + }, + "preinfusionSettings": { + "mode": "TypeB", + "Group1": [ + { + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseB", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.2999999523162842, + "preWetHoldTime": 3.2999999523162842 + }, + { + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 2, + "preWetHoldTime": 2 + } + ] + }, + "weeklySchedulingConfig": { + "enabled": true, + "monday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "tuesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "wednesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "thursday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "friday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "saturday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "sunday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + } + }, + "clock": "1901-07-08T10:29:00", + "firmwareVersions": [ + { + "name": "machine_firmware", + "fw_version": "1.40" + }, + { + "name": "gateway_firmware", + "fw_version": "v3.1-rc4" + } + ] +} diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json new file mode 100644 index 00000000000..f99c3d5c331 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/current_status.json @@ -0,0 +1,59 @@ +{ + "power": true, + "global_auto": "Enabled", + "enable_prebrewing": true, + "coffee_boiler_on": true, + "steam_boiler_on": true, + "enable_preinfusion": false, + "steam_boiler_enable": true, + "steam_temp": 113, + "steam_set_temp": 128, + "steam_level_set": 3, + "coffee_temp": 93, + "coffee_set_temp": 95, + "water_reservoir_contact": true, + "brew_active": false, + "drinks_k1": 13, + "drinks_k2": 2, + "drinks_k3": 42, + "drinks_k4": 34, + "total_flushing": 69, + "mon_auto": "Disabled", + "mon_on_time": "00:00", + "mon_off_time": "00:00", + "tue_auto": "Disabled", + "tue_on_time": "00:00", + "tue_off_time": "00:00", + "wed_auto": "Disabled", + "wed_on_time": "00:00", + "wed_off_time": "00:00", + "thu_auto": "Disabled", + "thu_on_time": "00:00", + "thu_off_time": "00:00", + "fri_auto": "Disabled", + "fri_on_time": "00:00", + "fri_off_time": "00:00", + "sat_auto": "Disabled", + "sat_on_time": "00:00", + "sat_off_time": "00:00", + "sun_auto": "Disabled", + "sun_on_time": "00:00", + "sun_off_time": "00:00", + "dose_k1": 1023, + "dose_k2": 1023, + "dose_k3": 1023, + "dose_k4": 1023, + "dose_hot_water": 1023, + "prebrewing_ton_k1": 3, + "prebrewing_toff_k1": 5, + "prebrewing_ton_k2": 3, + "prebrewing_toff_k2": 5, + "prebrewing_ton_k3": 3, + "prebrewing_toff_k3": 5, + "prebrewing_ton_k4": 3, + "prebrewing_toff_k4": 5, + "preinfusion_k1": 4, + "preinfusion_k2": 4, + "preinfusion_k3": 4, + "preinfusion_k4": 4 +} diff --git a/tests/components/lamarzocco/fixtures/statistics.json b/tests/components/lamarzocco/fixtures/statistics.json new file mode 100644 index 00000000000..c82d02cc7c1 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/statistics.json @@ -0,0 +1,26 @@ +[ + { + "count": 1047, + "coffeeType": 0 + }, + { + "count": 560, + "coffeeType": 1 + }, + { + "count": 468, + "coffeeType": 2 + }, + { + "count": 312, + "coffeeType": 3 + }, + { + "count": 2252, + "coffeeType": 4 + }, + { + "coffeeType": -1, + "count": 1740 + } +] diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..12acc6757e2 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_binary_sensors[GS01234_brewing_active-binary_sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'GS01234 Brewing active', + }), + 'context': , + 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[GS01234_brewing_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brewing active', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brew_active', + 'unique_id': 'GS01234_brew_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[GS01234_water_tank_empty-binary_sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'GS01234 Water tank empty', + }), + 'context': , + 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[GS01234_water_tank_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water tank empty', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank', + 'unique_id': 'GS01234_water_tank', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr new file mode 100644 index 00000000000..2f15c70c8cc --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_start_backflush + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Start backflush', + }), + 'context': , + 'entity_id': 'button.gs01234_start_backflush', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_start_backflush.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.gs01234_start_backflush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start backflush', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_backflush', + 'unique_id': 'GS01234_start_backflush', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ec44100fe1e --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -0,0 +1,298 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'boilerTargetTemperature': dict({ + 'CoffeeBoiler1': 95, + 'SteamBoiler': 123.9000015258789, + }), + 'boilers': list([ + dict({ + 'current': 123.80000305175781, + 'id': 'SteamBoiler', + 'isEnabled': True, + 'target': 123.9000015258789, + }), + dict({ + 'current': 96.5, + 'id': 'CoffeeBoiler1', + 'isEnabled': True, + 'target': 95, + }), + ]), + 'clock': '1901-07-08T10:29:00', + 'firmwareVersions': list([ + dict({ + 'fw_version': '1.40', + 'name': 'machine_firmware', + }), + dict({ + 'fw_version': 'v3.1-rc4', + 'name': 'gateway_firmware', + }), + ]), + 'groupCapabilities': list([ + dict({ + 'capabilities': dict({ + 'boilerId': 'CoffeeBoiler1', + 'groupNumber': 'Group1', + 'groupType': 'AV_Group', + 'hasFlowmeter': True, + 'hasScale': False, + 'numberOfDoses': 4, + }), + 'doseMode': dict({ + 'brewingType': 'PulsesType', + 'groupNumber': 'Group1', + }), + 'doses': list([ + dict({ + 'doseIndex': 'DoseA', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 135, + }), + dict({ + 'doseIndex': 'DoseB', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 97, + }), + dict({ + 'doseIndex': 'DoseC', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 108, + }), + dict({ + 'doseIndex': 'DoseD', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 121, + }), + ]), + }), + ]), + 'isBackFlushEnabled': False, + 'isPlumbedIn': True, + 'machineCapabilities': list([ + dict({ + 'coffeeBoilersNumber': 1, + 'family': 'GS3AV', + 'groupsNumber': 1, + 'hasCupWarmer': False, + 'machineModes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'schedulingType': 'weeklyScheduling', + 'steamBoilersNumber': 1, + 'teaDosesNumber': 1, + }), + ]), + 'machineMode': 'BrewingMode', + 'machine_hw': '2', + 'machine_sn': '**REDACTED**', + 'preinfusionMode': dict({ + 'Group1': dict({ + 'groupNumber': 'Group1', + 'preinfusionStyle': 'PreinfusionByDoseType', + }), + }), + 'preinfusionModesAvailable': list([ + 'ByDoseType', + ]), + 'preinfusionSettings': dict({ + 'Group1': list([ + dict({ + 'doseType': 'DoseA', + 'groupNumber': 'Group1', + 'preWetHoldTime': 1, + 'preWetTime': 0.5, + }), + dict({ + 'doseType': 'DoseB', + 'groupNumber': 'Group1', + 'preWetHoldTime': 1, + 'preWetTime': 0.5, + }), + dict({ + 'doseType': 'DoseC', + 'groupNumber': 'Group1', + 'preWetHoldTime': 3.299999952316284, + 'preWetTime': 3.299999952316284, + }), + dict({ + 'doseType': 'DoseD', + 'groupNumber': 'Group1', + 'preWetHoldTime': 2, + 'preWetTime': 2, + }), + ]), + 'mode': 'TypeB', + }), + 'standByTime': 0, + 'tankStatus': True, + 'teaDoses': dict({ + 'DoseA': dict({ + 'doseIndex': 'DoseA', + 'stopTarget': 8, + }), + }), + 'version': 'v1', + 'weeklySchedulingConfig': dict({ + 'enabled': True, + 'friday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'monday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'saturday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'sunday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'thursday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'tuesday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'wednesday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + }), + }), + 'current_status': dict({ + 'brew_active': False, + 'coffee_boiler_on': True, + 'coffee_set_temp': 95, + 'coffee_temp': 93, + 'dose_hot_water': 1023, + 'dose_k1': 1023, + 'dose_k2': 1023, + 'dose_k3': 1023, + 'dose_k4': 1023, + 'drinks_k1': 13, + 'drinks_k2': 2, + 'drinks_k3': 42, + 'drinks_k4': 34, + 'enable_prebrewing': True, + 'enable_preinfusion': False, + 'fri_auto': 'Disabled', + 'fri_off_time': '00:00', + 'fri_on_time': '00:00', + 'global_auto': 'Enabled', + 'mon_auto': 'Disabled', + 'mon_off_time': '00:00', + 'mon_on_time': '00:00', + 'power': True, + 'prebrewing_toff_k1': 5, + 'prebrewing_toff_k2': 5, + 'prebrewing_toff_k3': 5, + 'prebrewing_toff_k4': 5, + 'prebrewing_ton_k1': 3, + 'prebrewing_ton_k2': 3, + 'prebrewing_ton_k3': 3, + 'prebrewing_ton_k4': 3, + 'preinfusion_k1': 4, + 'preinfusion_k2': 4, + 'preinfusion_k3': 4, + 'preinfusion_k4': 4, + 'sat_auto': 'Disabled', + 'sat_off_time': '00:00', + 'sat_on_time': '00:00', + 'steam_boiler_enable': True, + 'steam_boiler_on': True, + 'steam_level_set': 3, + 'steam_set_temp': 128, + 'steam_temp': 113, + 'sun_auto': 'Disabled', + 'sun_off_time': '00:00', + 'sun_on_time': '00:00', + 'thu_auto': 'Disabled', + 'thu_off_time': '00:00', + 'thu_on_time': '00:00', + 'total_flushing': 69, + 'tue_auto': 'Disabled', + 'tue_off_time': '00:00', + 'tue_on_time': '00:00', + 'water_reservoir_contact': True, + 'wed_auto': 'Disabled', + 'wed_off_time': '00:00', + 'wed_on_time': '00:00', + }), + 'firmware': dict({ + 'gateway': dict({ + 'latest_version': 'v3.1-rc4', + 'version': 'v2.2-rc0', + }), + 'machine': dict({ + 'latest_version': '1.2', + 'version': '1.1', + }), + }), + 'machine_info': dict({ + 'machine_name': 'GS01234', + 'serial_number': '**REDACTED**', + }), + 'statistics': dict({ + 'stats': list([ + dict({ + 'coffeeType': 0, + 'count': 1047, + }), + dict({ + 'coffeeType': 1, + 'count': 560, + }), + dict({ + 'coffeeType': 2, + 'count': 468, + }), + dict({ + 'coffeeType': 3, + 'count': 312, + }), + dict({ + 'coffeeType': 4, + 'count': 2252, + }), + dict({ + 'coffeeType': -1, + 'count': 1740, + }), + ]), + }), + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr new file mode 100644 index 00000000000..3c9fdce101f --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -0,0 +1,271 @@ +# serializer version: 1 +# name: test_coffee_boiler + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Coffee target temperature', + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '95', + }) +# --- +# name: test_coffee_boiler.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coffee target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'coffee_temp', + 'unique_id': 'GS01234_coffee_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr new file mode 100644 index 00000000000..4f64eafb855 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -0,0 +1,217 @@ +# serializer version: 1 +# name: test_pre_brew_infusion_select[GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Prebrew/-infusion mode', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'GS01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_pre_brew_infusion_select[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LM01234 Prebrew/-infusion mode', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'LM01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_pre_brew_infusion_select[Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR01234 Prebrew/-infusion mode', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'MR01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_steam_boiler_level[Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR01234 Steam level', + 'options': list([ + '1', + '2', + '3', + ]), + }), + 'context': , + 'entity_id': 'select.mr01234_steam_level', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_steam_boiler_level[Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mr01234_steam_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steam level', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp_select', + 'unique_id': 'MR01234_steam_temp_select', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e0b04289f7c --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_sensors[GS01234_current_coffee_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.gs01234_current_coffee_temperature', + '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': 'Current coffee temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_coffee', + 'unique_id': 'GS01234_current_temp_coffee', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_coffee_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current coffee temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'last_changed': , + 'last_updated': , + 'state': '93', + }) +# --- +# name: test_sensors[GS01234_current_steam_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.gs01234_current_steam_temperature', + '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': 'Current steam temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_steam', + 'unique_id': 'GS01234_current_temp_steam', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_steam_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current steam temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'last_changed': , + 'last_updated': , + 'state': '113', + }) +# --- +# name: test_sensors[GS01234_shot_timer-entry] + EntityRegistryEntrySnapshot({ + '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.gs01234_shot_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Shot timer', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shot_timer', + 'unique_id': 'GS01234_shot_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_shot_timer-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Shot timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_shot_timer', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-entry] + EntityRegistryEntrySnapshot({ + '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.gs01234_total_coffees_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total coffees made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee', + 'unique_id': 'GS01234_drink_stats_coffee', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total coffees made', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_coffees_made', + 'last_changed': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-entry] + EntityRegistryEntrySnapshot({ + '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.gs01234_total_flushes_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total flushes made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_flushing', + 'unique_id': 'GS01234_drink_stats_flushing', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total flushes made', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_flushes_made', + 'last_changed': , + 'last_updated': , + 'state': '69', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr new file mode 100644 index 00000000000..789e979894e --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -0,0 +1,158 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lamarzocco', + 'GS01234', + ), + }), + 'is_new': False, + 'manufacturer': 'La Marzocco', + 'model': 'GS3 AV', + 'name': 'GS01234', + 'name_by_user': None, + 'serial_number': 'GS01234', + 'suggested_area': None, + 'sw_version': '1.1', + 'via_device_id': None, + }) +# --- +# name: test_switches[-set_power] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234', + }), + 'context': , + 'entity_id': 'switch.gs01234', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[-set_power].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234', + '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': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main', + 'unique_id': 'GS01234_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Auto on/off', + }), + 'context': , + 'entity_id': 'switch.gs01234_auto_on_off', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global].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.gs01234_auto_on_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS01234_auto_on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_steam_boiler-set_steam] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Steam boiler', + }), + 'context': , + 'entity_id': 'switch.gs01234_steam_boiler', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_steam_boiler-set_steam].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234_steam_boiler', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steam boiler', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_boiler', + 'unique_id': 'GS01234_steam_boiler_enable', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr new file mode 100644 index 00000000000..a1ee4de2c4b --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -0,0 +1,109 @@ +# serializer version: 1 +# name: test_update_entites[gateway_firmware-gateway] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', + 'friendly_name': 'GS01234 Gateway firmware', + 'in_progress': False, + 'installed_version': 'v2.2-rc0', + 'latest_version': 'v3.1-rc4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.gs01234_gateway_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entites[gateway_firmware-gateway].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.gs01234_gateway_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gateway firmware', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'gateway_firmware', + 'unique_id': 'GS01234_gateway_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entites[machine_firmware-machine] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', + 'friendly_name': 'GS01234 Machine firmware', + 'in_progress': False, + 'installed_version': '1.1', + 'latest_version': '1.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.gs01234_machine_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entites[machine_firmware-machine].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.gs01234_machine_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine firmware', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'machine_firmware', + 'unique_id': 'GS01234_machine_firmware', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py new file mode 100644 index 00000000000..e475e663768 --- /dev/null +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -0,0 +1,71 @@ +"""Tests for La Marzocco binary sensors.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +BINARY_SENSORS = ( + "brewing_active", + "water_tank_empty", +) + + +async def test_binary_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco binary sensors.""" + + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + + for binary_sensor in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.{serial_number}_{binary_sensor}") + assert state + assert state == snapshot(name=f"{serial_number}_{binary_sensor}-binary_sensor") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"{serial_number}_{binary_sensor}-entry") + + +@pytest.mark.usefixtures("remove_local_connection") +async def test_brew_active_does_not_exists( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") + assert state is None + + +async def test_brew_active_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco currently_making_coffee becomes unavailable.""" + + mock_lamarzocco.websocket_connected = False + await async_init_integration(hass, mock_config_entry) + state = hass.states.get( + f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" + ) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py new file mode 100644 index 00000000000..7d910a57561 --- /dev/null +++ b/tests/components/lamarzocco/test_button.py @@ -0,0 +1,45 @@ +"""Tests for the La Marzocco Buttons.""" + + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_start_backflush( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco backflush button.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"button.{serial_number}_start_backflush") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.start_backflush.mock_calls) == 1 + mock_lamarzocco.start_backflush.assert_called_once() diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py new file mode 100644 index 00000000000..e8500ee427d --- /dev/null +++ b/tests/components/lamarzocco/test_config_flow.py @@ -0,0 +1,239 @@ +"""Test the La Marzocco config flow.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant import config_entries +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import USER_INPUT + +from tests.common import MockConfigEntry + + +async def __do_successful_user_step( + hass: HomeAssistant, result: FlowResult +) -> FlowResult: + """Successfully configure the user step.""" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + return result2 + + +async def __do_sucessful_machine_selection_step( + hass: HomeAssistant, result2: FlowResult, mock_lamarzocco: MagicMock +) -> None: + """Successfully configure the machine selection step.""" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + + assert result3["title"] == mock_lamarzocco.serial_number + assert result3["data"] == { + **USER_INPUT, + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + } + + +async def test_form(hass: HomeAssistant, mock_lamarzocco: MagicMock) -> 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"] == {} + assert result["step_id"] == "user" + + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 + + +async def test_form_abort_already_configured( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + + mock_lamarzocco.get_all_machines.side_effect = AuthFail("") + 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, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_invalid_host( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + mock_lamarzocco.check_local_connection.return_value = False + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == {"host": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.check_local_connection.return_value = True + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test cannot connect error.""" + + mock_lamarzocco.get_all_machines.return_value = [] + + 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, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "no_machines"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + mock_lamarzocco.get_all_machines.side_effect = RequestNotSuccessful("") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + mock_lamarzocco.get_all_machines.return_value = [ + (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) + ] + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_reauth_flow( + hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that the reauth flow.""" + + 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=mock_config_entry.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new_password"}, + ) + + assert result2["type"] == FlowResultType.ABORT + await hass.async_block_till_done() + assert result2["reason"] == "reauth_successful" + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" diff --git a/tests/components/lamarzocco/test_diagnostics.py b/tests/components/lamarzocco/test_diagnostics.py new file mode 100644 index 00000000000..a42b15dec3c --- /dev/null +++ b/tests/components/lamarzocco/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the diagnostics data provided by the La Marzocco 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/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py new file mode 100644 index 00000000000..91243c76eaf --- /dev/null +++ b/tests/components/lamarzocco/test_init.py @@ -0,0 +1,70 @@ +"""Test initialization of lamarzocco.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant.components.lamarzocco.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_lamarzocco: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test the La Marzocco configuration entry not ready.""" + mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test auth error during setup.""" + mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + + 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/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py new file mode 100644 index 00000000000..7a9eb334637 --- /dev/null +++ b/tests/components/lamarzocco/test_number.py @@ -0,0 +1,128 @@ +"""Tests for the La Marzocco number entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoModel +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_coffee_boiler( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco coffee temperature Number.""" + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_coffee_target_temperature", + ATTR_VALUE: 95, + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_coffee_temp.mock_calls) == 1 + mock_lamarzocco.set_coffee_temp.assert_called_once_with(temperature=95) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP] +) +@pytest.mark.parametrize( + ("entity_name", "value", "func_name", "kwargs"), + [ + ("steam_target_temperature", 131, "set_steam_temp", {"temperature": 131}), + ("tea_water_duration", 15, "set_dose_hot_water", {"value": 15}), + ], +) +async def test_gs3_exclusive( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + value: float, + func_name: str, + kwargs: dict[str, float], +) -> None: + """Test exclusive entities for GS3 AV/MP.""" + + serial_number = mock_lamarzocco.serial_number + + func = getattr(mock_lamarzocco, func_name) + + state = hass.states.get(f"number.{serial_number}_{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", + ATTR_VALUE: value, + }, + blocking=True, + ) + + assert len(func.mock_calls) == 1 + func.assert_called_once_with(**kwargs) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI] +) +async def test_gs3_exclusive_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure GS3 exclusive is None for unsupported models.""" + + ENTITIES = ("steam_target_temperature", "tea_water_duration") + + serial_number = mock_lamarzocco.serial_number + for entity in ENTITIES: + state = hass.states.get(f"number.{serial_number}_{entity}") + assert state is None diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py new file mode 100644 index 00000000000..a2e4248f0af --- /dev/null +++ b/tests/components/lamarzocco/test_select.py @@ -0,0 +1,124 @@ +"""Tests for the La Marzocco select entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoModel +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.LINEA_MICRA]) +async def test_steam_boiler_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco Steam Level Select (only for Micra Models).""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"select.{serial_number}_steam_level") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # on/off service calls + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.{serial_number}_steam_level", + ATTR_OPTION: "1", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_steam_level.mock_calls) == 1 + mock_lamarzocco.set_steam_level.assert_called_once_with(level=1) + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP, LaMarzoccoModel.LINEA_MINI], +) +async def test_steam_boiler_level_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + serial_number = mock_lamarzocco.serial_number + state = hass.states.get(f"select.{serial_number}_steam_level") + + assert state is None + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.GS3_AV, LaMarzoccoModel.LINEA_MINI], +) +async def test_pre_brew_infusion_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Prebrew/-infusion select.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # on/off service calls + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.{serial_number}_prebrew_infusion_mode", + ATTR_OPTION: "preinfusion", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.select_pre_brew_infusion_mode.mock_calls) == 1 + mock_lamarzocco.select_pre_brew_infusion_mode.assert_called_once_with( + mode="Preinfusion" + ) + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.GS3_MP], +) +async def test_pre_brew_infusion_select_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + serial_number = mock_lamarzocco.serial_number + state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") + + assert state is None diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py new file mode 100644 index 00000000000..3333fed1464 --- /dev/null +++ b/tests/components/lamarzocco/test_sensor.py @@ -0,0 +1,72 @@ +"""Tests for La Marzocco sensors.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +SENSORS = ( + "total_coffees_made", + "total_flushes_made", + "shot_timer", + "current_coffee_temperature", + "current_steam_temperature", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco sensors.""" + + serial_number = mock_lamarzocco.serial_number + + await async_init_integration(hass, mock_config_entry) + + for sensor in SENSORS: + state = hass.states.get(f"sensor.{serial_number}_{sensor}") + assert state + assert state == snapshot(name=f"{serial_number}_{sensor}-sensor") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"{serial_number}_{sensor}-entry") + + +@pytest.mark.usefixtures("remove_local_connection") +async def test_shot_timer_not_exists( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco shot timer doesn't exist if host not set.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state is None + + +async def test_shot_timer_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco brew_active becomes unavailable.""" + + mock_lamarzocco.websocket_connected = False + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py new file mode 100644 index 00000000000..70024e3e340 --- /dev/null +++ b/tests/components/lamarzocco/test_switch.py @@ -0,0 +1,91 @@ +"""Tests for La Marzocco switches.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +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_name", "method_name"), + [ + ("", "set_power"), + ("_auto_on_off", "set_auto_on_off_global"), + ("_steam_boiler", "set_steam"), + ], +) +async def test_switches( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + method_name: str, +) -> None: + """Test the La Marzocco switches.""" + serial_number = mock_lamarzocco.serial_number + + control_fn = getattr(mock_lamarzocco, method_name) + + state = hass.states.get(f"switch.{serial_number}{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 1 + control_fn.assert_called_once_with(False) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 2 + control_fn.assert_called_with(True) + + +async def test_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device for one switch.""" + + state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") + assert state + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py new file mode 100644 index 00000000000..55c5bb0da3d --- /dev/null +++ b/tests/components/lamarzocco/test_update.py @@ -0,0 +1,76 @@ +"""Tests for the La Marzocco Update Entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoUpdateableComponent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + ("entity_name", "component"), + [ + ("machine_firmware", LaMarzoccoUpdateableComponent.MACHINE), + ("gateway_firmware", LaMarzoccoUpdateableComponent.GATEWAY), + ], +) +async def test_update_entites( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + component: LaMarzoccoUpdateableComponent, +) -> None: + """Test the La Marzocco update entities.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"update.{serial_number}_{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}", + }, + blocking=True, + ) + + mock_lamarzocco.update_firmware.assert_called_once_with(component) + + +async def test_update_error( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Test error during update.""" + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware") + assert state + + mock_lamarzocco.update_firmware.return_value = False + + with pytest.raises(HomeAssistantError, match="Update failed"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware", + }, + blocking=True, + ) diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py new file mode 100644 index 00000000000..c54e07ccd87 --- /dev/null +++ b/tests/components/leaone/__init__.py @@ -0,0 +1,39 @@ +"""Tests for the Leaone integration.""" + + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +SCALE_SERVICE_INFO = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94"}, + service_uuids=[], + service_data={}, + source="local", +) +SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={ + 57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94", + 63424: b"\x06\xa4\x13\x80\x00\x021_Z\\R\xd3\x94", + }, + service_uuids=[], + service_data={}, + source="local", +) +SCALE_SERVICE_INFO_3 = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={ + 57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94", + 63424: b"\x06\xa4\x13\x80\x00\x021_Z\\R\xd3\x94", + 6592: b"\x06\x8e\x00\x00\x00\x020_Z\\R\xd3\x94", + }, + service_uuids=[], + service_data={}, + source="local", +) diff --git a/tests/components/leaone/conftest.py b/tests/components/leaone/conftest.py new file mode 100644 index 00000000000..2f89e80f893 --- /dev/null +++ b/tests/components/leaone/conftest.py @@ -0,0 +1,8 @@ +"""Leaone session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/leaone/test_config_flow.py b/tests/components/leaone/test_config_flow.py new file mode 100644 index 00000000000..b7e4abdcf6b --- /dev/null +++ b/tests/components/leaone/test_config_flow.py @@ -0,0 +1,94 @@ +"""Test the Leaone config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.leaone.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import SCALE_SERVICE_INFO + +from tests.common import MockConfigEntry + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant) -> None: + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + 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("homeassistant.components.leaone.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "5F:5A:5C:52:D3:94"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "TZC4 D394" + assert result2["data"] == {} + assert result2["result"].unique_id == "5F:5A:5C:52:D3:94" + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + 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" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.leaone.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "5F:5A:5C:52:D3:94"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup( + hass: HomeAssistant, +) -> None: + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/leaone/test_sensor.py b/tests/components/leaone/test_sensor.py new file mode 100644 index 00000000000..ccf520a7eb7 --- /dev/null +++ b/tests/components/leaone/test_sensor.py @@ -0,0 +1,54 @@ +"""Test the Leaone sensors.""" + +from homeassistant.components.leaone.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from . import SCALE_SERVICE_INFO, SCALE_SERVICE_INFO_2, SCALE_SERVICE_INFO_3 + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO) + await hass.async_block_till_done() + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO_2) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + mass_sensor = hass.states.get("sensor.tzc4_d394_mass") + mass_sensor_attrs = mass_sensor.attributes + assert mass_sensor.state == "77.11" + assert mass_sensor_attrs[ATTR_FRIENDLY_NAME] == "TZC4 D394 Mass" + assert mass_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + mass_sensor = hass.states.get("sensor.tzc4_d394_non_stabilized_mass") + mass_sensor_attrs = mass_sensor.attributes + assert mass_sensor.state == "77.11" + assert mass_sensor_attrs[ATTR_FRIENDLY_NAME] == "TZC4 D394 Non Stabilized Mass" + assert mass_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO_3) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/life360/test_config_flow.py b/tests/components/life360/test_config_flow.py deleted file mode 100644 index 7eec67fc0cc..00000000000 --- a/tests/components/life360/test_config_flow.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Test the Life360 config flow.""" -from unittest.mock import patch - -from life360 import Life360Error, LoginError -import pytest -import voluptuous as vol - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.life360.const import ( - CONF_AUTHORIZATION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DEFAULT_OPTIONS, - DOMAIN, - SHOW_DRIVING, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - -TEST_USER = "Test@Test.com" -TEST_PW = "password" -TEST_PW_3 = "password_3" -TEST_AUTHORIZATION = "authorization_string" -TEST_AUTHORIZATION_2 = "authorization_string_2" -TEST_AUTHORIZATION_3 = "authorization_string_3" -TEST_MAX_GPS_ACCURACY = "300" -TEST_DRIVING_SPEED = "18" -TEST_SHOW_DRIVING = True - -USER_INPUT = {CONF_USERNAME: TEST_USER, CONF_PASSWORD: TEST_PW} - -TEST_CONFIG_DATA = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW, - CONF_AUTHORIZATION: TEST_AUTHORIZATION, -} -TEST_CONFIG_DATA_2 = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW, - CONF_AUTHORIZATION: TEST_AUTHORIZATION_2, -} -TEST_CONFIG_DATA_3 = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW_3, - CONF_AUTHORIZATION: TEST_AUTHORIZATION_3, -} - -USER_OPTIONS = { - "limit_gps_acc": True, - CONF_MAX_GPS_ACCURACY: TEST_MAX_GPS_ACCURACY, - "set_drive_speed": True, - CONF_DRIVING_SPEED: TEST_DRIVING_SPEED, - SHOW_DRIVING: TEST_SHOW_DRIVING, -} -TEST_OPTIONS = { - CONF_MAX_GPS_ACCURACY: float(TEST_MAX_GPS_ACCURACY), - CONF_DRIVING_SPEED: float(TEST_DRIVING_SPEED), - SHOW_DRIVING: TEST_SHOW_DRIVING, -} - - -# ========== Common Fixtures & Functions =============================================== - - -@pytest.fixture(name="life360", autouse=True) -def life360_fixture(): - """Mock life360 config entry setup & unload.""" - with patch( - "homeassistant.components.life360.async_setup_entry", return_value=True - ), patch("homeassistant.components.life360.async_unload_entry", return_value=True): - yield - - -@pytest.fixture -def life360_api(): - """Mock Life360 api.""" - with patch( - "homeassistant.components.life360.config_flow.Life360", autospec=True - ) as mock: - yield mock.return_value - - -def create_config_entry(hass, state=None): - """Create mock config entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONFIG_DATA, - version=1, - state=state, - options=DEFAULT_OPTIONS, - unique_id=TEST_USER.lower(), - ) - config_entry.add_to_hass(hass) - return config_entry - - -# ========== User Flow Tests =========================================================== - - -async def test_user_show_form(hass: HomeAssistant, life360_api) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_not_called() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - - schema = result["data_schema"].schema - assert set(schema) == set(USER_INPUT) - # username and password fields should be empty. - keys = list(schema) - for key in USER_INPUT: - assert keys[keys.index(key)].default == vol.UNDEFINED - - -async def test_user_config_flow_success(hass: HomeAssistant, life360_api) -> None: - """Test a successful user config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.return_value = TEST_AUTHORIZATION - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_USER.lower() - assert result["data"] == TEST_CONFIG_DATA - assert result["options"] == DEFAULT_OPTIONS - - -@pytest.mark.parametrize( - ("exception", "error"), - [(LoginError, "invalid_auth"), (Life360Error, "cannot_connect")], -) -async def test_user_config_flow_error( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture, exception, error -) -> None: - """Test a user config flow with an error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.side_effect = exception("test reason") - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] - assert result["errors"]["base"] == error - - assert "test reason" in caplog.text - - schema = result["data_schema"].schema - assert set(schema) == set(USER_INPUT) - # username and password fields should be prefilled with current values. - keys = list(schema) - for key, val in USER_INPUT.items(): - default = keys[keys.index(key)].default - assert default != vol.UNDEFINED - assert default() == val - - -async def test_user_config_flow_already_configured( - hass: HomeAssistant, life360_api -) -> None: - """Test a user config flow with an account already configured.""" - create_config_entry(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"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_not_called() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -# ========== Reauth Flow Tests ========================================================= - - -@pytest.mark.parametrize("state", [None, config_entries.ConfigEntryState.LOADED]) -async def test_reauth_config_flow_success( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture, state -) -> None: - """Test a successful reauthorization config flow.""" - config_entry = create_config_entry(hass, state=state) - - # Simulate current username & password are still valid, but authorization string has - # expired, such that getting a new authorization string from server is successful. - life360_api.get_authorization.return_value = TEST_AUTHORIZATION_2 - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data=config_entry.data, - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert "Reauthorization successful" in caplog.text - - assert config_entry.data == TEST_CONFIG_DATA_2 - - -async def test_reauth_config_flow_login_error( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture -) -> None: - """Test a reauthorization config flow with a login error.""" - config_entry = create_config_entry(hass) - - # Simulate current username & password are invalid, which results in a form - # requesting new password, with old password as default value. - life360_api.get_authorization.side_effect = LoginError("test reason") - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data=config_entry.data, - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] - assert result["errors"]["base"] == "invalid_auth" - - assert "test reason" in caplog.text - - schema = result["data_schema"].schema - assert len(schema) == 1 - assert "password" in schema - key = list(schema)[0] - assert key.default() == TEST_PW - - # Simulate hitting RECONFIGURE button. - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] - assert result["errors"]["base"] == "invalid_auth" - - # Simulate getting a new, valid password. - life360_api.get_authorization.reset_mock(side_effect=True) - life360_api.get_authorization.return_value = TEST_AUTHORIZATION_3 - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: TEST_PW_3} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert "Reauthorization successful" in caplog.text - - assert config_entry.data == TEST_CONFIG_DATA_3 - - -# ========== Option flow Tests ========================================================= - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test an options flow.""" - config_entry = create_config_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - 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 not result["errors"] - - schema = result["data_schema"].schema - assert set(schema) == set(USER_OPTIONS) - - flow_id = result["flow_id"] - - result = await hass.config_entries.options.async_configure(flow_id, USER_OPTIONS) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == TEST_OPTIONS - - assert config_entry.options == TEST_OPTIONS diff --git a/tests/components/life360/test_init.py b/tests/components/life360/test_init.py new file mode 100644 index 00000000000..0a781f6f2b2 --- /dev/null +++ b/tests/components/life360/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the MyQ Connected Services integration.""" + +from homeassistant.components.life360 import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_life360_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Life360 configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 903002063e8..69f6a841737 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2610,3 +2610,51 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> caplog.clear() assert entity.supported_features_compat is light.LightEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + +@pytest.mark.parametrize( + ("color_mode", "supported_color_modes", "effect", "warning_expected"), + [ + (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, None, False), + # A light which supports brightness should not set its color mode to on_off + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, None, True), + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, light.EFFECT_OFF, True), + # Unless it renders an effect + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, "effect", False), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.BRIGHTNESS}, "effect", False), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.BRIGHTNESS}, None, False), + # A light which supports color should not set its color mode to brightnes + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, None, True), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, light.EFFECT_OFF, True), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, None, True), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, light.EFFECT_OFF, True), + # Unless it renders an effect + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, "effect", False), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, "effect", False), + (light.ColorMode.HS, {light.ColorMode.HS}, "effect", False), + # A light which supports brightness should not set its color mode to hs even + # if rendering an effect + (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), + ], +) +def test_report_invalid_color_mode( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + color_mode: str, + supported_color_modes: set[str], + effect: str | None, + warning_expected: bool, +) -> None: + """Test a light setting an invalid color mode.""" + + class MockLightEntityEntity(light.LightEntity): + _attr_color_mode = color_mode + _attr_effect = effect + _attr_is_on = True + _attr_supported_features = light.LightEntityFeature.EFFECT + _attr_supported_color_modes = supported_color_modes + + entity = MockLightEntityEntity() + entity._async_calculate_state() + expected_warning = f"set to unsupported color_mode: {color_mode}" + assert (expected_warning in caplog.text) is warning_expected diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index e2b2829de9e..b490643f622 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -6,8 +6,7 @@ from serial import SerialException from homeassistant import config_entries, data_entry_flow from homeassistant.components.litejet.const import CONF_DEFAULT_TRANSITION, DOMAIN from homeassistant.const import CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -68,63 +67,6 @@ async def test_flow_open_failed(hass: HomeAssistant) -> None: assert result["errors"][CONF_PORT] == "open_failed" -async def test_import_step(hass: HomeAssistant, mock_litejet) -> None: - """Test initializing via import step.""" - test_data = {CONF_PORT: "/dev/imported"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == "create_entry" - assert result["title"] == test_data[CONF_PORT] - assert result["data"] == test_data - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_step_fails(hass: HomeAssistant) -> None: - """Test initializing via import step fails due to can't open port.""" - test_data = {CONF_PORT: "/dev/test"} - with patch("pylitejet.LiteJet") as mock_pylitejet: - mock_pylitejet.side_effect = SerialException - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"port": "open_failed"} - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_serial_exception") - - -async def test_import_step_already_exist(hass: HomeAssistant) -> None: - """Test initializing via import step when entry already exist.""" - first_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_PORT: "/dev/imported"}, - ) - first_entry.add_to_hass(hass) - - test_data = {CONF_PORT: "/dev/imported"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_PORT: "/dev/test"}) diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py index fdaeeefc867..c6f0d5c5b02 100644 --- a/tests/components/litejet/test_init.py +++ b/tests/components/litejet/test_init.py @@ -1,7 +1,6 @@ """The tests for the litejet component.""" from homeassistant.components import litejet from homeassistant.components.litejet.const import DOMAIN -from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,15 +13,6 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert DOMAIN not in hass.data -async def test_setup_with_config_to_import(hass: HomeAssistant, mock_litejet) -> None: - """Test that import happens.""" - assert ( - await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PORT: "/dev/hello"}}) - is True - ) - assert DOMAIN in hass.data - - async def test_unload_entry(hass: HomeAssistant, mock_litejet) -> None: """Test being able to unload an entry.""" entry = await async_init_integration(hass, use_switch=True, use_scene=True) diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index fe77119ca5e..c2df2bc5095 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -13,8 +13,6 @@ from homeassistant.components.vacuum import ( DOMAIN as PLATFORM_DOMAIN, SERVICE_START, SERVICE_STOP, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, STATE_DOCKED, STATE_ERROR, ) @@ -102,16 +100,6 @@ async def test_vacuum_with_error( [ (SERVICE_START, "start_cleaning", None), (SERVICE_STOP, "set_power_status", None), - ( - SERVICE_TURN_OFF, - "set_power_status", - {"issues": {(DOMAIN, "service_deprecation_turn_off")}}, - ), - ( - SERVICE_TURN_ON, - "set_power_status", - {"issues": {(DOMAIN, "service_deprecation_turn_on")}}, - ), ( SERVICE_SET_SLEEP_MODE, "set_sleep_mode", @@ -150,53 +138,3 @@ async def test_commands( issue_registry = ir.async_get(hass) assert set(issue_registry.issues.keys()) == issues - - -@pytest.mark.parametrize( - ("service", "issue_id", "placeholders"), - [ - ( - SERVICE_TURN_OFF, - "service_deprecation_turn_off", - { - "old_service": "vacuum.turn_off", - "new_service": "vacuum.stop", - }, - ), - ( - SERVICE_TURN_ON, - "service_deprecation_turn_on", - { - "old_service": "vacuum.turn_on", - "new_service": "vacuum.start", - }, - ), - ], -) -async def test_issues( - hass: HomeAssistant, - mock_account: MagicMock, - caplog: pytest.LogCaptureFixture, - service: str, - issue_id: str, - placeholders: dict[str, str], -) -> None: - """Test issues raised by calling deprecated services.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.state == STATE_DOCKED - - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - {ATTR_ENTITY_ID: VACUUM_ENTITY_ID}, - blocking=True, - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue.is_fixable is True - assert issue.is_persistent is True - assert issue.translation_placeholders == placeholders diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 8861a166bed..7a1e071958d 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -20,7 +20,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def locative_client(event_loop, hass, hass_client): +async def locative_client(hass, hass_client): """Locative mock client.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index 9fe6c2b60a8..824bbbde21d 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -77,6 +77,7 @@ def mock_humanify(hass_, rows): context_augmenter = processor.ContextAugmenter(logbook_run) return list( processor._humanify( + hass_, rows, ent_reg, logbook_run, diff --git a/tests/components/logbook/test_models.py b/tests/components/logbook/test_models.py index 6f3c6bfefcb..dcafd7e4765 100644 --- a/tests/components/logbook/test_models.py +++ b/tests/components/logbook/test_models.py @@ -12,9 +12,15 @@ def test_lazy_event_partial_state_context(): context_user_id_bin=b"1234123412341234", context_parent_id_bin=b"4444444444444444", event_data={}, + event_type="event_type", + entity_id="entity_id", + state="state", ), {}, ) assert state.context_id == "1H68SK8C9J6CT32CHK6GRK4CSM" assert state.context_user_id == "31323334313233343132333431323334" assert state.context_parent_id == "1M6GT38D1M6GT38D1M6GT38D1M" + assert state.event_type == "event_type" + assert state.entity_id == "entity_id" + assert state.state == "state" diff --git a/tests/components/lupusec/__init__.py b/tests/components/lupusec/__init__.py new file mode 100644 index 00000000000..32d708e986b --- /dev/null +++ b/tests/components/lupusec/__init__.py @@ -0,0 +1 @@ +"""Define tests for the lupusec component.""" diff --git a/tests/components/lupusec/test_config_flow.py b/tests/components/lupusec/test_config_flow.py new file mode 100644 index 00000000000..6b07952ff54 --- /dev/null +++ b/tests/components/lupusec/test_config_flow.py @@ -0,0 +1,231 @@ +""""Unit tests for the Lupusec config flow.""" + +from json import JSONDecodeError +from unittest.mock import patch + +from lupupy import LupusecException +import pytest + +from homeassistant import config_entries +from homeassistant.components.lupusec.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_HOST: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +MOCK_IMPORT_STEP = { + CONF_IP_ADDRESS: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +MOCK_IMPORT_STEP_NAME = { + CONF_IP_ADDRESS: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_NAME: "test-name", +} + + +async def test_form_valid_input(hass: HomeAssistant) -> None: + """Test handling valid user input.""" + 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.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", + ) as mock_initialize_lupusec: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == MOCK_DATA_STEP[CONF_HOST] + assert result2["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (LupusecException("Test lupusec exception"), "cannot_connect"), + (JSONDecodeError("Test JSONDecodeError", "test", 1), "cannot_connect"), + (Exception("Test unknown exception"), "unknown"), + ], +) +async def test_flow_user_init_data_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test exceptions and recovery.""" + 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.lupusec.config_flow.lupupy.Lupusec", + side_effect=raise_error, + ) as mock_initialize_lupusec: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": text_error} + + assert len(mock_initialize_lupusec.mock_calls) == 1 + + # Recover + with patch( + "homeassistant.components.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", + ) as mock_initialize_lupusec: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == MOCK_DATA_STEP[CONF_HOST] + assert result3["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test duplicate config entry..""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DATA_STEP[CONF_HOST], + data=MOCK_DATA_STEP, + ) + + 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("mock_import_step", "mock_title"), + [ + (MOCK_IMPORT_STEP, MOCK_IMPORT_STEP[CONF_IP_ADDRESS]), + (MOCK_IMPORT_STEP_NAME, MOCK_IMPORT_STEP_NAME[CONF_NAME]), + ], +) +async def test_flow_source_import( + hass: HomeAssistant, mock_import_step, mock_title +) -> None: + """Test configuration import from YAML.""" + with patch( + "homeassistant.components.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", + ) as mock_initialize_lupusec: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=mock_import_step, + ) + + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == mock_title + assert result["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (LupusecException("Test lupusec exception"), "cannot_connect"), + (JSONDecodeError("Test JSONDecodeError", "test", 1), "cannot_connect"), + (Exception("Test unknown exception"), "unknown"), + ], +) +async def test_flow_source_import_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test exceptions and recovery.""" + + with patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", + side_effect=raise_error, + ) as mock_initialize_lupusec: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT_STEP, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +async def test_flow_source_import_already_configured(hass: HomeAssistant) -> None: + """Test duplicate config entry..""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DATA_STEP[CONF_HOST], + data=MOCK_DATA_STEP, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/lutron/__init__.py b/tests/components/lutron/__init__.py new file mode 100644 index 00000000000..6ffe0ee7d94 --- /dev/null +++ b/tests/components/lutron/__init__.py @@ -0,0 +1 @@ +"""Test for the lutron integration.""" diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py new file mode 100644 index 00000000000..e94e337ce1d --- /dev/null +++ b/tests/components/lutron/conftest.py @@ -0,0 +1,15 @@ +"""Provide common Lutron fixtures and mocks.""" + +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.lutron.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py new file mode 100644 index 00000000000..b1f4b3365c9 --- /dev/null +++ b/tests/components/lutron/test_config_flow.py @@ -0,0 +1,215 @@ +"""Test the lutron config flow.""" +from email.message import Message +from unittest.mock import AsyncMock, patch +from urllib.error import HTTPError + +import pytest + +from homeassistant.components.lutron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test success response.""" + 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.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (HTTPError("", 404, "", Message(), None), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + 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.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_incorrect_guid( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test configuring flow with incorrect guid.""" + 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.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_flow_single_instance_allowed(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA_STEP, unique_id="12345678901") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +MOCK_DATA_IMPORT = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": 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", "reason"), + [ + (HTTPError("", 404, "", Message(), None), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_flow_failure( + hass: HomeAssistant, raise_error: Exception, reason: str +) -> None: + """Test handling errors while importing.""" + + with patch( + "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_import_flow_guid_failure(hass: HomeAssistant) -> None: + """Test handling errors while importing.""" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "123" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_DATA_IMPORT, unique_id="12345678901" + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9ca8ac9e2ba..14e28b0999d 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -1221,7 +1221,7 @@ async def test_restore_state(hass: HomeAssistant, expected_state) -> None: """Ensure state is restored on startup.""" mock_restore_cache(hass, (State("alarm_control_panel.test", expected_state),)) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1266,7 +1266,7 @@ async def test_restore_state_arming(hass: HomeAssistant, expected_state) -> None hass, (State(entity_id, expected_state, attributes, last_updated=time),) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1323,7 +1323,7 @@ async def test_restore_state_pending(hass: HomeAssistant, previous_state) -> Non (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1388,7 +1388,7 @@ async def test_restore_state_triggered(hass: HomeAssistant, previous_state) -> N (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1434,7 +1434,7 @@ async def test_restore_state_triggered_long_ago(hass: HomeAssistant) -> None: (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( diff --git a/tests/components/matter/fixtures/nodes/air-quality-sensor.json b/tests/components/matter/fixtures/nodes/air-quality-sensor.json new file mode 100644 index 00000000000..4a533f0a166 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/air-quality-sensor.json @@ -0,0 +1,288 @@ +{ + "node_id": 1, + "date_commissioned": "2024-01-13T20:12:42.853855", + "last_interview": "2024-01-13T20:12:42.853862", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Nordic Semiconductor ASA", + "0/40/2": 65521, + "0/40/3": "lightfi-aq1-air-quality-sensor", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "prerelease", + "0/40/9": 0, + "0/40/10": "prerelease", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/21": null, + "0/40/22": null, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "q/UPJlJtKwk=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "q/UPJlJtKwk=", + "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, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "SrFuqi7GOA0=", + "5": [], + "6": [ + "/oAAAAAAAABIsW6qLsY4DQ==", + "/a2BRTrZUShjZ6Plq5dszA==", + "/R/knXM6xXGK6bPqGglOHw==" + ], + "7": 4 + } + ], + "0/51/1": 12, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEVW22H4oSAH8ygJxeAehOa+Fy5OvrpZP1+OsUuhMOHK8xRrUY021wlmdjeKyX3Fnax3+QU5eXXNyJl8B4KDS8wTcKNQEoARgkAgE2AwQCBAEYMAQUwvHAaN24tRt6l5HJQbyntNkVQZIwBRRje8c2OVfVDK5m9OcVHaS51jcEChgwC0A5oKtEonnnHfT+Ut+H359m/kiVNMmVkroDCeBWKItO6T28kladkvO0iHB8J1L7QFLEsDxv9YuCBOPa0T7fUHb6GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEa/1FpraZtnACK49eqDofsCGh7KwwBIKj28CVug2c1v7Nk+jy6Alq83vfKRc1MR6+9Lp31clVfzhOpCsX0vYiXjcKNQEpARgkAmAwBBRje8c2OVfVDK5m9OcVHaS51jcECjAFFBM9Maievp4UKHYUW3dZjm1BsGxwGDALQPa5FY9kZ/Ii83D0I4eCXwdSUEQWPIYeCyodb/eO1p0gtUzTbRxYmYFBTaE2bMVQHoZ7KFnC1uBvv6T/ELrxTD0Y", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BBfkzpqcInPpJmIWMy2yckRYs4V/CHeqvr7ObEtvRywP9sQSuJ2vIIJer+Af5gA/5sld0ZRaOCdBksUK3b5g4/w=", + "2": 24582, + "3": 4448312386606703954, + "4": 11636151610245023439, + "5": "", + "254": 2 + }, + { + "1": "BJSwLCRLiMCNDkJINo2xgNg4Q4DQOnPH/UjP6AVITT6YFza4r9itL7nPg3TJo7quKWfdZ1aksO7doJZvFo5WyUU=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AycU2fdVLSD8xXAYJgSAVCstJgWAWsNSNwYnFNn3VS0g/MVwGCQHASQIATAJQQQX5M6anCJz6SZiFjMtsnJEWLOFfwh3qr6+zmxLb0csD/bEEridryCCXq/gH+YAP+bJXdGUWjgnQZLFCt2+YOP8Nwo1ASkBGCQCYDAEFEMcdLs9Y7GImXEqgx7gT7WdJXEmMAUUQxx0uz1jsYiZcSqDHuBPtZ0lcSYYMAtA8OCKQmQJSw32MwkiKh2yCqXwqPo5ZFqC5KIju6EhVyic45AZqc8XooMha/G87qtjpG4X6zh4aEdwOJGgMVoewxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEElLAsJEuIwI0OQkg2jbGA2DhDgNA6c8f9SM/oBUhNPpgXNriv2K0vuc+DdMmjuq4pZ91nVqSw7t2glm8WjlbJRTcKNQEpARgkAmAwBBQTPTGonr6eFCh2FFt3WY5tQbBscDAFFBM9Maievp4UKHYUW3dZjm1BsGxwGDALQEoomYkckgebSt7QekvI/9ZPf9y5pCFq7Vi+3bWwduTHa560n9hFZ01anVu4UxOsx1cn8erdu/dVdkHIBtfCKrcY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "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/29/0": [ + { + "0": 44, + "1": 1 + } + ], + "1/29/1": [3, 29, 91, 1026, 1029, 1037, 1043, 1066, 1068, 1069, 1070], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/91/0": null, + "1/91/65532": null, + "1/91/65533": 1, + "1/91/65528": [], + "1/91/65529": [], + "1/91/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/1026/0": 2008, + "1/1026/1": null, + "1/1026/2": null, + "1/1026/65532": 0, + "1/1026/65533": 1, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1029/0": 2875, + "1/1029/1": 0, + "1/1029/2": 0, + "1/1029/65532": 0, + "1/1029/65533": 3, + "1/1029/65528": [], + "1/1029/65529": [], + "1/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1037/0": 678.0, + "1/1037/1": 0.0, + "1/1037/2": 5000.0, + "1/1037/65532": 1, + "1/1037/65533": 3, + "1/1037/65528": [], + "1/1037/65529": [], + "1/1037/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1043/0": 0.0, + "1/1043/1": 0.0, + "1/1043/2": 500.0, + "1/1043/65532": 1, + "1/1043/65533": 3, + "1/1043/65528": [], + "1/1043/65529": [], + "1/1043/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1066/0": 3.0, + "1/1066/1": 0.0, + "1/1066/2": 1000.0, + "1/1066/65532": 1, + "1/1066/65533": 3, + "1/1066/65528": [], + "1/1066/65529": [], + "1/1066/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1068/0": 3.0, + "1/1068/1": 0.0, + "1/1068/2": 1000.0, + "1/1068/65532": 1, + "1/1068/65533": 3, + "1/1068/65528": [], + "1/1068/65529": [], + "1/1068/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1069/0": 3.0, + "1/1069/1": 0.0, + "1/1069/2": 1000.0, + "1/1069/65532": 1, + "1/1069/65533": 3, + "1/1069/65528": [], + "1/1069/65529": [], + "1/1069/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1070/0": 189.0, + "1/1070/1": 0.0, + "1/1070/2": 500.0, + "1/1070/65532": 1, + "1/1070/65533": 3, + "1/1070/65528": [], + "1/1070/65529": [], + "1/1070/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [ + [1, 1037, 0], + [1, 1070, 0], + [1, 1066, 0], + [1, 1068, 0], + [1, 1069, 0], + [1, 1026, 0], + [1, 1029, 0] + ] +} diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 45d1c18635c..370e028e721 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -206,47 +206,17 @@ "1": 1 } ], - "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], + "1/29/1": [3, 4, 6, 8, 29, 80, 768], "1/29/2": [], "1/29/3": [], "1/29/65533": 1, "1/29/65528": [], "1/29/65529": [], "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65533], - "1/768/0": 36, - "1/768/3": 26515, - "1/768/4": 25657, "1/768/7": 284, "1/768/1": 51, - "1/768/16384": 9305, "1/768/8": 0, - "1/768/15": 0, - "1/768/16385": 0, - "1/768/16386": { - "TLVValue": null, - "Reason": null - }, - "1/768/16387": { - "TLVValue": null, - "Reason": null - }, - "1/768/16388": { - "TLVValue": null, - "Reason": null - }, - "1/768/16389": { - "TLVValue": null, - "Reason": null - }, - "1/768/16390": { - "TLVValue": null, - "Reason": null - }, - "1/768/16394": 25, - "1/768/16": { - "TLVValue": null, - "Reason": null - }, + "1/768/16394": 16, "1/768/16395": 153, "1/768/16396": 500, "1/768/16397": 250, diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index 7ccc3eef3af..74f132a88a9 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -358,54 +358,14 @@ "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/1": [3, 4, 6, 8, 29], "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/768/0": 0, - "1/768/1": 0, - "1/768/2": 0, - "1/768/3": 24939, - "1/768/4": 24701, - "1/768/7": 0, - "1/768/8": 2, - "1/768/15": 0, - "1/768/16": 0, - "1/768/16384": 0, - "1/768/16385": 2, - "1/768/16386": 0, - "1/768/16387": 0, - "1/768/16388": 25, - "1/768/16389": 8960, - "1/768/16390": 0, - "1/768/16394": 31, - "1/768/16395": 0, - "1/768/16396": 65279, - "1/768/16397": 0, - "1/768/16400": 0, - "1/768/65532": 31, - "1/768/65533": 5, - "1/768/65528": [], - "1/768/65529": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ], - "1/768/65531": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, - 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, - 65533 - ], - "1/1030/0": 0, - "1/1030/1": 0, - "1/1030/2": 1, - "1/1030/65532": 0, - "1/1030/65533": 3, - "1/1030/65528": [], - "1/1030/65529": [], - "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] }, "available": true, "attribute_subscriptions": [] diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index eed404ff85d..15390129dd2 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -330,82 +330,20 @@ "1/6/65531": [ 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 ], - "1/8/0": 52, - "1/8/1": 0, - "1/8/2": 1, - "1/8/3": 254, - "1/8/4": 0, - "1/8/5": 0, - "1/8/6": 0, - "1/8/15": 0, - "1/8/16": 0, - "1/8/17": null, - "1/8/18": 0, - "1/8/19": 0, - "1/8/20": 50, - "1/8/16384": null, - "1/8/65532": 3, - "1/8/65533": 5, - "1/8/65528": [], - "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], - "1/8/65531": [ - 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, - 65532, 65533 - ], "1/29/0": [ { - "0": 257, + "0": 256, "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/1": [3, 4, 6, 29], "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/768/0": 0, - "1/768/1": 0, - "1/768/2": 0, - "1/768/3": 24939, - "1/768/4": 24701, - "1/768/7": 0, - "1/768/8": 2, - "1/768/15": 0, - "1/768/16": 0, - "1/768/16384": 0, - "1/768/16385": 2, - "1/768/16386": 0, - "1/768/16387": 0, - "1/768/16388": 25, - "1/768/16389": 8960, - "1/768/16390": 0, - "1/768/16394": 31, - "1/768/16395": 0, - "1/768/16396": 65279, - "1/768/16397": 0, - "1/768/16400": 0, - "1/768/65532": 31, - "1/768/65533": 5, - "1/768/65528": [], - "1/768/65529": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ], - "1/768/65531": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, - 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, - 65533 - ], - "1/1030/0": 0, - "1/1030/1": 0, - "1/1030/2": 1, - "1/1030/65532": 0, - "1/1030/65533": 3, - "1/1030/65528": [], - "1/1030/65529": [], - "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] }, "available": true, "attribute_subscriptions": [] diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 24dac910d33..892f935ebab 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -1,11 +1,28 @@ """Test the api module.""" -from unittest.mock import MagicMock, call +from unittest.mock import AsyncMock, MagicMock, call +from matter_server.client.models.node import ( + MatterFabricData, + NetworkType, + NodeDiagnostics, + NodeType, +) from matter_server.common.errors import InvalidCommand, NodeCommissionFailed +from matter_server.common.helpers.util import dataclass_to_dict +from matter_server.common.models import CommissioningParameters import pytest -from homeassistant.components.matter.api import ID, TYPE +from homeassistant.components.matter.api import ( + DEVICE_ID, + ERROR_NODE_NOT_FOUND, + ID, + TYPE, +) +from homeassistant.components.matter.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .common import setup_integration_with_node_fixture from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -177,3 +194,307 @@ async def test_set_wifi_credentials( assert matter_client.set_wifi_credentials.call_args == call( ssid="test_network", credentials="test_password" ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_node_diagnostics( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the node diagnostics command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create a mock NodeDiagnostics + mock_diagnostics = NodeDiagnostics( + node_id=1, + network_type=NetworkType.WIFI, + node_type=NodeType.END_DEVICE, + network_name="SuperCoolWiFi", + ip_adresses=["192.168.1.1", "fe80::260:97ff:fe02:6ea5"], + mac_address="00:11:22:33:44:55", + available=True, + active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")], + ) + matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/node_diagnostics", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + diag_res = dataclass_to_dict(mock_diagnostics) + # dataclass to dict allows enums which are converted to string when serializing + diag_res["network_type"] = diag_res["network_type"].value + diag_res["node_type"] = diag_res["node_type"].value + assert msg["result"] == diag_res + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/node_diagnostics", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_ping_node( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the ping_node command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create a mocked ping result + ping_result = {"192.168.1.1": False, "fe80::260:97ff:fe02:6ea5": True} + matter_client.ping_node = AsyncMock(return_value=ping_result) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/ping_node", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + assert msg["result"] == ping_result + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/ping_node", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_open_commissioning_window( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the open_commissioning_window command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create mocked CommissioningParameters + commissioning_parameters = CommissioningParameters( + setup_pin_code=51590642, + setup_manual_code="36296231484", + setup_qr_code="MT:00000CQM008-WE3G310", + ) + matter_client.open_commissioning_window = AsyncMock( + return_value=commissioning_parameters + ) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/open_commissioning_window", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + assert msg["result"] == dataclass_to_dict(commissioning_parameters) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/open_commissioning_window", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_remove_matter_fabric( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the remove_matter_fabric command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/remove_matter_fabric", + DEVICE_ID: entry.id, + "fabric_index": 3, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + matter_client.remove_matter_fabric.assert_called_once_with(1, 3) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/remove_matter_fabric", + DEVICE_ID: new_entry.id, + "fabric_index": 3, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_interview_node( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the interview_node command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + {ID: 1, TYPE: "matter/interview_node", DEVICE_ID: entry.id} + ) + msg = await ws_client.receive_json() + assert msg["success"] + matter_client.interview_node.assert_called_once_with(1) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/interview_node", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 5b343b8c4e5..579dd7d94c5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -76,6 +76,16 @@ async def eve_energy_plug_node_fixture( ) +@pytest.fixture(name="air_quality_sensor_node") +async def air_quality_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an air quality sensor (LightFi AQ1) node.""" + return await setup_integration_with_node_fixture( + hass, "air-quality-sensor", 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( @@ -288,3 +298,60 @@ async def test_eve_energy_sensors( state = hass.states.get(entity_id) assert state assert state.state == "5.0" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_air_quality_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + air_quality_sensor_node: MatterNode, +) -> None: + """Test air quality sensor.""" + # Carbon Dioxide + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_carbon_dioxide") + assert state + assert state.state == "678.0" + + set_node_attribute(air_quality_sensor_node, 1, 1037, 0, 789) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_carbon_dioxide") + assert state + assert state.state == "789.0" + + # PM1 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm1") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1068, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm1") + assert state + assert state.state == "50.0" + + # PM2.5 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm2_5") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1066, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm2_5") + assert state + assert state.state == "50.0" + + # PM10 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1069, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") + assert state + assert state.state == "50.0" diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 3f2b325330e..76ab214f3ac 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -89,7 +89,10 @@ async def test_setup_thermostat( assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE assert ( state.attributes.get(ATTR_SUPPORTED_FEATURES) - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get(ATTR_MAX_TEMP) == MAX_TEMPERATURE assert state.attributes.get(ATTR_MIN_TEMP) == 5.0 diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index c60f67031cf..d32ad90d87c 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/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index f3d49f3c0bc..5e8614a555c 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -7,12 +7,11 @@ from aiohttp import ClientError, ClientResponseError import pymelcloud import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries 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.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -57,8 +56,6 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None: assert result["errors"] is None with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.melcloud.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -73,7 +70,6 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None: "username": "test-email@test-domain.com", "token": "test-token", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -122,138 +118,6 @@ async def test_form_response_errors( assert result["reason"] == message -@pytest.mark.parametrize( - ("error", "message", "issue"), - [ - ( - HTTPStatus.UNAUTHORIZED, - "invalid_auth", - "deprecated_yaml_import_issue_invalid_auth", - ), - ( - HTTPStatus.FORBIDDEN, - "invalid_auth", - "deprecated_yaml_import_issue_invalid_auth", - ), - ( - HTTPStatus.INTERNAL_SERVER_ERROR, - "cannot_connect", - "deprecated_yaml_import_issue_cannot_connect", - ), - ], -) -async def test_step_import_fails( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, - error: Exception, - message: str, - issue: str, -) -> None: - """Test raising issues on import.""" - mock_get_devices.side_effect = ClientResponseError( - mock_request_info(), (), status=error - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-email@test-domain.com", "token": "test-token"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == message - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue(DOMAIN, issue) - - -async def test_step_import_fails_ClientError( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, -) -> None: - """Test raising issues on import for ClientError.""" - mock_get_devices.side_effect = ClientError() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-email@test-domain.com", "token": "test-token"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue( - DOMAIN, "deprecated_yaml_import_issue_cannot_connect" - ) - - -async def test_step_import_already_exist( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, -) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {"username": "test-email@test-domain.com", "token": "test-token"} - config_entry = MockConfigEntry( - domain=DOMAIN, - data=conf, - title=conf["username"], - unique_id=conf["username"], - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_melcloud" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_with_token( - hass: HomeAssistant, mock_login, mock_get_devices -) -> None: - """Test successful import.""" - with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.melcloud.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-email@test-domain.com", "token": "test-token"}, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "test-email@test-domain.com" - assert result["data"] == { - "username": "test-email@test-domain.com", - "token": "test-token", - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_melcloud" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) -> None: """Re-configuration with existing username should refresh token.""" mock_entry = MockConfigEntry( @@ -264,8 +128,6 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) mock_entry.add_to_hass(hass) with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.melcloud.async_setup_entry", return_value=True ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( @@ -280,7 +142,6 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) assert result["type"] == "abort" assert result["reason"] == "already_configured" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index 4568eaf2e77..dc2ca4391f1 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -223,7 +223,10 @@ async def test_supported_features(hass: HomeAssistant) -> None: device = (await api.async_fetch_devices())[_SERIAL] thermostat = MelissaClimate(api, _SERIAL, device) features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert thermostat.supported_features == features diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index 95a67644606..bb0a017611f 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -48,7 +48,7 @@ async def test_user_step_discovered_devices( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "pick_device" - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ADDRESS: "wrong_address"} ) @@ -95,7 +95,7 @@ async def test_user_step_with_existing_device( assert result["type"] == FlowResultType.FORM - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} ) diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index 80155d3311a..0405f8efa18 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -80,9 +80,6 @@ def mock_controller_client_single(): def mock_setup(): """Prevent setup.""" with patch( - "homeassistant.components.meteo_france.async_setup", - return_value=True, - ), patch( "homeassistant.components.meteo_france.async_setup_entry", return_value=True, ): @@ -155,21 +152,6 @@ async def test_user_list(hass: HomeAssistant, client_multiple) -> None: assert result["data"][CONF_LONGITUDE] == str(CITY_3_LON) -async def test_import(hass: HomeAssistant, client_multiple) -> None: - """Test import step.""" - # import with all - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_2_NAME}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}" - assert result["title"] == f"{CITY_2}" - assert result["data"][CONF_LATITUDE] == str(CITY_2_LAT) - assert result["data"][CONF_LONGITUDE] == str(CITY_2_LON) - - async def test_search_failed(hass: HomeAssistant, client_empty) -> None: """Test error displayed if no result in search.""" result = await hass.config_entries.flow.async_init( @@ -190,15 +172,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant, client_single) -> Non unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ).add_to_hass(hass) - # Should fail, same CITY same postal code (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_1_POSTAL}, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - # Should fail, same CITY same postal code (flow) result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py index 1633fae5ee8..117bfe417e3 100644 --- a/tests/components/metoffice/conftest.py +++ b/tests/components/metoffice/conftest.py @@ -1,13 +1,14 @@ """Fixtures for Met Office weather integration tests.""" -import sys from unittest.mock import patch import pytest -if sys.version_info < (3, 12): - from datapoint.exceptions import APIException -else: - collect_ignore_glob = ["test_*.py"] +# All tests are marked as disabled, as the integration is disabled in the +# integration manifest. `datapoint` isn't compatible with Python 3.12 +# +# from datapoint.exceptions import APIException +APIException = Exception +collect_ignore_glob = ["test_*.py"] @pytest.fixture diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index f7c4a5690db..c1414533fd7 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -53,7 +53,7 @@ async def test_sensor( "type": "sensor", "entity_category": "diagnostic", "unique_id": "battery_temp", - "state_class": "total", + "state_class": "measurement", "unit_of_measurement": UnitOfTemperature.CELSIUS, }, }, @@ -73,7 +73,7 @@ async def test_sensor( # unit of temperature sensor is automatically converted to the system UoM assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" - assert entity.attributes["state_class"] == "total" + assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" assert entity.state == state1 @@ -175,7 +175,7 @@ async def test_sensor_migration( "type": "sensor", "entity_category": "diagnostic", "unique_id": unique_id, - "state_class": "total", + "state_class": "measurement", "unit_of_measurement": UnitOfTemperature.CELSIUS, }, }, @@ -195,7 +195,7 @@ async def test_sensor_migration( # unit of temperature sensor is automatically converted to the system UoM assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" - assert entity.attributes["state_class"] == "total" + assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" assert entity.state == state1 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 6fe272fbc40..f7581f03241 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -318,7 +318,7 @@ async def test_webhook_handle_get_config( "unit_system": hass_config["unit_system"], "location_name": hass_config["location_name"], "time_zone": hass_config["time_zone"], - "components": hass_config["components"], + "components": set(hass_config["components"]), "version": hass_config["version"], "theme_color": "#03A9F4", # Default frontend theme color "entities": { @@ -962,7 +962,7 @@ async def test_reregister_sensor( "state": 100, "type": "sensor", "unique_id": "abcd", - "state_class": "total", + "state_class": "measurement", "device_class": "battery", "entity_category": "diagnostic", "icon": "mdi:new-icon", diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 325b68869e0..3ff9aa37bcf 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -30,6 +30,7 @@ from homeassistant.components.modbus.const import ( 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, @@ -309,6 +310,36 @@ async def test_temperature_climate( assert hass.states.get(ENTITY_ID).state == expected +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 1, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_DATA_TYPE: DataType.INT32, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("register_words", "expected"), + [ + ( + None, + "unavailable", + ), + ], +) +async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) -> None: + """Run test for given config.""" + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ @@ -461,7 +492,7 @@ async def test_service_climate_update( CONF_SCAN_INTERVAL: 0, CONF_DATA_TYPE: DataType.INT32, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 118, + CONF_ADDRESS: [118], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_LOW: 0, CONF_FAN_MODE_MEDIUM: 1, @@ -475,6 +506,31 @@ async def test_service_climate_update( FAN_HIGH, [0x02], ), + ( + { + 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_FAN_MODE_TOP: 3, + }, + }, + }, + ] + }, + FAN_TOP, + [0x03], + ), ], ) async def test_service_climate_fan_update( @@ -710,7 +766,7 @@ async def test_service_set_hvac_mode( CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 118, + CONF_ADDRESS: [118], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_ON: 1, CONF_FAN_MODE_OFF: 2, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index df415807119..3c932a24afb 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -56,6 +56,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RETRIES, CONF_RETRY_ON_EMPTY, CONF_SLAVE_COUNT, CONF_STOPBITS, @@ -82,7 +83,7 @@ from homeassistant.components.modbus.validators import ( duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, - number_validator, + register_int_list_validator, struct_validator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -137,26 +138,22 @@ async def mock_modbus_with_pymodbus_fixture(hass, caplog, do_config, mock_pymodb return mock_pymodbus -async def test_number_validator() -> None: - """Test number validator.""" - - for value, value_type in ( +async def test_register_int_list_validator() -> None: + """Test conf address register validator.""" + for value, vtype in ( (15, int), - (15.1, float), - ("15", int), - ("15.1", float), - (-15, int), - (-15.1, float), - ("-15", int), - ("-15.1", float), + ([15], list), ): - assert isinstance(number_validator(value), value_type) + assert isinstance(register_int_list_validator(value), vtype) - try: - number_validator("x15.1") - except vol.Invalid: - return - pytest.fail("Number_validator not throwing exception") + with pytest.raises(vol.Invalid): + register_int_list_validator([15, 16]) + + with pytest.raises(vol.Invalid): + register_int_list_validator(-15) + + with pytest.raises(vol.Invalid): + register_int_list_validator(["aq"]) async def test_nan_validator() -> None: @@ -583,7 +580,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_SLAVE: 0, CONF_TARGET_TEMP: 117, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 121, + CONF_ADDRESS: [121], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_ON: 0, CONF_FAN_MODE_HIGH: 1, @@ -610,6 +607,12 @@ async def test_duplicate_entity_validator_with_climate(do_config) -> None: CONF_PORT: TEST_PORT_TCP, CONF_CLOSE_COMM_ON_ERROR: True, }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_RETRIES: 3, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 8fb7f9fd951..97571041482 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -173,6 +174,17 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT32, + CONF_VIRTUAL_COUNT: 5, + CONF_LAZY_ERROR: 3, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -433,7 +445,7 @@ async def test_config_wrong_struct_sensor( }, [0x89AB, 0xCDEF, 0x0123, 0x4567], False, - "9920249030613615975", + "9920249030613616640", ), ( { @@ -444,7 +456,7 @@ async def test_config_wrong_struct_sensor( }, [0x0123, 0x4567, 0x89AB, 0xCDEF], False, - "163971058432973793", + "163971058432973792", ), ( { @@ -674,7 +686,7 @@ async def test_config_wrong_struct_sensor( }, [0x00AB, 0xCDEF], False, - "112593.75", + "112594", ), ], ) @@ -715,7 +727,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392", "0"], + ["34899771392.0", "0.0"], ), ( { @@ -730,7 +742,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392", "0"], + ["34899771392.0", "0.0"], ), ( { @@ -925,7 +937,7 @@ async def test_virtual_sensor( }, [0x0102, 0x0304, 0x0506, 0x0708], False, - [str(0x0708050603040102)], + [str(0x0708050603040100)], ), ( { @@ -958,7 +970,7 @@ async def test_virtual_sensor( }, [0x0102, 0x0304, 0x0506, 0x0708, 0x0901, 0x0902, 0x0903, 0x0904], False, - [str(0x0708050603040102), str(0x0904090309020901)], + [str(0x0708050603040100), str(0x0904090309020900)], ), ( { @@ -1023,10 +1035,10 @@ async def test_virtual_sensor( ], False, [ - str(0x0604060306020601), - str(0x0704070307020701), - str(0x0804080308020801), - str(0x0904090309020901), + str(0x0604060306020600), + str(0x0704070307020700), + str(0x0804080308020800), + str(0x0904090309020900), ], ), ], @@ -1190,7 +1202,7 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: 0x0000, 0x000A, ], - "0,10", + "0,10.00", ), ( { diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9bb5c8b2585..6fcb219f6b6 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -121,6 +121,17 @@ async def test_setup_params( assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP assert state.attributes.get("min_humidity") == DEFAULT_MIN_HUMIDITY assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY + assert ( + state.attributes.get("supported_features") + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) @pytest.mark.parametrize( @@ -226,6 +237,8 @@ async def test_supported_features( | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get("supported_features") == support @@ -1327,6 +1340,8 @@ async def test_set_aux( | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get("supported_features") == support diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index cb5ff53d7e9..54b0f7f3506 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -43,6 +43,7 @@ DEFAULT_CONFIG_DEVICE_INFO_ID = { "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "suggested_area": "default_area", "configuration_url": "http://example.com", @@ -54,6 +55,7 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "suggested_area": "default_area", "configuration_url": "http://example.com", diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c2a7e0065ce..9e86a3554d6 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -672,7 +672,7 @@ async def test_keepalive_validation( assert result["step_id"] == "broker" if error: - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=test_input, diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 90360bf7e3f..ade28ac2c1d 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -70,7 +70,6 @@ async def test_get_triggers( "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -191,7 +190,6 @@ async def test_discover_bad_triggers( "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -207,12 +205,13 @@ async def test_update_remove_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test triggers can be updated and removed.""" await mqtt_mock_entry() config1 = { "automation_type": "trigger", - "device": {"identifiers": ["0AFFD2"]}, + "device": {"identifiers": ["0AFFD2"], "name": "milk"}, "payload": "short_press", "topic": "foobar/triggers/button1", "type": "button_short_press", @@ -223,25 +222,36 @@ async def test_update_remove_triggers( config2 = { "automation_type": "trigger", - "device": {"identifiers": ["0AFFD2"]}, + "device": {"identifiers": ["0AFFD2"], "name": "beer"}, + "payload": "short_press", + "topic": "foobar/triggers/button1", + "type": "button_short_press", + "subtype": "button_1", + } + config2["topic"] = "foobar/tag_scanned2" + data2 = json.dumps(config2) + + config3 = { + "automation_type": "trigger", + "device": {"identifiers": ["0AFFD2"], "name": "beer"}, "payload": "short_press", "topic": "foobar/triggers/button1", "type": "button_short_press", "subtype": "button_2", } - config2["topic"] = "foobar/tag_scanned2" - data2 = json.dumps(config2) + config3["topic"] = "foobar/tag_scanned2" + data3 = json.dumps(config3) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "milk" expected_triggers1 = [ { "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -254,11 +264,21 @@ async def test_update_remove_triggers( hass, DeviceAutomationType.TRIGGER, device_entry.id ) assert triggers == unordered(expected_triggers1) + assert device_entry.name == "milk" - # Update trigger + # Update trigger topic async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2) await hass.async_block_till_done() + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert triggers == unordered(expected_triggers1) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "beer" + # Update trigger type / subtype + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data3) + await hass.async_block_till_done() triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -275,7 +295,7 @@ async def test_update_remove_triggers( async def test_if_fires_on_mqtt_message( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" @@ -351,10 +371,202 @@ async def test_if_fires_on_mqtt_message( assert calls[1].data["some"] == "long_press" +async def test_if_discovery_id_is_prefered( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test if discovery is preferred over referencing by type/subtype. + + The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + By default, a MQTT device trigger now will be referenced by + device_id, type and subtype instead. + If discovery_id is found an an automation it will have a higher + priority and than type and subtype. + """ + await mqtt_mock_entry() + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + # type and subtype of data 2 do not match with the type and subtype + # in the automation, because discovery_id matches, the trigger will fire + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_long_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla2", + "type": "completely_different_type", + "subtype": "completely_different_sub_type", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("long_press")}, + }, + }, + ] + }, + ) + + # Fake short press, matching on type and subtype + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "short_press" + + # Fake long press, matching on discovery_id + calls.clear() + async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "long_press" + + +async def test_non_unique_triggers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test non unique triggers.""" + await mqtt_mock_entry() + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"], "name": "milk"},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "press",' + ' "subtype": "button" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"], "name": "beer"},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button2",' + ' "type": "press",' + ' "subtype": "button" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "milk" + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + # The device entry was updated, but the trigger was not unique + # and therefore it was not set up. + assert device_entry.name == "beer" + assert ( + "Config for device trigger bla2 conflicts with existing device trigger, cannot set up trigger" + in caplog.text + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "press", + "subtype": "button", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("press1")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "press", + "subtype": "button", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("press2")}, + }, + }, + ] + }, + ) + + # Try to trigger first config. + # and triggers both attached instances. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "press1" + assert calls[1].data["some"] == "press2" + + # Trigger second config references to same trigger + # and triggers both attached instances. + async_fire_mqtt_message(hass, "foobar/triggers/button2", "long_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "press1" + assert calls[1].data["some"] == "press2" + + # Removing the first trigger will clean up + calls.clear() + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "") + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "Device trigger ('device_automation', 'bla1') has been removed" in caplog.text + ) + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + assert len(calls) == 0 + + async def test_if_fires_on_mqtt_message_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" @@ -435,7 +647,7 @@ async def test_if_fires_on_mqtt_message_template( async def test_if_fires_on_mqtt_message_late_discover( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing of MQTT device triggers discovered after setup.""" @@ -522,8 +734,9 @@ async def test_if_fires_on_mqtt_message_late_discover( async def test_if_fires_on_mqtt_message_after_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test triggers firing after update.""" await mqtt_mock_entry() @@ -537,11 +750,19 @@ async def test_if_fires_on_mqtt_message_after_update( data2 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' - ' "topic": "foobar/triggers/buttonOne",' - ' "type": "button_long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' ' "subtype": "button_2" }' ) + data3 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/buttonOne",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) @@ -574,29 +795,38 @@ async def test_if_fires_on_mqtt_message_after_update( await hass.async_block_till_done() assert len(calls) == 1 + # Update the trigger with existing type/subtype change + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data1) + await hass.async_block_till_done() + assert "Cannot update device trigger ('device_automation', 'bla2')" in caplog.text + # Update the trigger with different topic - async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data3) await hass.async_block_till_done() + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/button1", "") await hass.async_block_till_done() + assert len(calls) == 0 + + calls.clear() + async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") + await hass.async_block_till_done() assert len(calls) == 1 - async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") - await hass.async_block_till_done() - assert len(calls) == 2 - # Update the trigger with same topic - async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data3) await hass.async_block_till_done() + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/button1", "") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(calls) == 0 + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(calls) == 1 async def test_no_resubscribe_same_topic( @@ -649,7 +879,7 @@ async def test_no_resubscribe_same_topic( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" @@ -715,7 +945,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" @@ -992,6 +1222,7 @@ async def test_entity_device_info_with_connection( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -1008,6 +1239,7 @@ async def test_entity_device_info_with_connection( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -1031,6 +1263,7 @@ async def test_entity_device_info_with_identifier( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -1045,6 +1278,7 @@ async def test_entity_device_info_with_identifier( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -1067,6 +1301,7 @@ async def test_entity_device_info_update( "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -1406,9 +1641,9 @@ async def test_trigger_debug_info( config1 = { "platform": "mqtt", "automation_type": "trigger", - "topic": "test-topic", + "topic": "test-topic1", "type": "foo", - "subtype": "bar", + "subtype": "bar1", "device": { "connections": [[dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12"]], "manufacturer": "Whatever", @@ -1422,7 +1657,7 @@ async def test_trigger_debug_info( "automation_type": "trigger", "topic": "test-topic2", "type": "foo", - "subtype": "bar", + "subtype": "bar2", "device": { "connections": [[dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12"]], }, @@ -1472,7 +1707,7 @@ async def test_trigger_debug_info( async def test_unload_entry( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, ) -> None: diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 017d24a39ce..9acd15eea7c 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -481,11 +481,11 @@ async def test_discover_alarm_control_panel( "vacuum", ), ( - "homeassistant/vacuum/object/bla/config", - '{ "name": "Hello World 17", "obj_id": "hello_id", "state_topic": "test-topic", "schema": "legacy" }', - "vacuum.hello_id", + "homeassistant/valve/object/bla/config", + '{ "name": "Hello World 17", "obj_id": "hello_id", "state_topic": "test-topic" }', + "valve.hello_id", "Hello World 17", - "vacuum", + "valve", ), ( "homeassistant/lock/object/bla/config", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 98e2c9b71fe..bfbf4e8670c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -31,12 +31,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, - template, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType @@ -49,7 +44,6 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, MockEntity, - async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -810,6 +804,7 @@ def test_entity_device_info_schema() -> None: "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "configuration_url": "http://example.com", } @@ -825,6 +820,7 @@ def test_entity_device_info_schema() -> None: "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "via_device": "test-hub", "configuration_url": "http://example.com", @@ -2159,98 +2155,6 @@ async def test_setup_manual_mqtt_with_invalid_config( assert "required key not provided" in caplog.text -@pytest.mark.parametrize( - ("hass_config", "entity_id"), - [ - ( - { - mqtt.DOMAIN: { - "sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, - "sensor.test", - ), - ( - { - mqtt.DOMAIN: { - "binary_sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, - "binary_sensor.test", - ), - ], -) -@patch( - "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] -) -async def test_setup_manual_mqtt_with_invalid_entity_category( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - entity_id: str, -) -> None: - """Test set up a manual sensor item with an invalid entity category.""" - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - assert await mqtt_mock_entry() - assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text - state = hass.states.get(entity_id) - assert state is not None - assert len(events) == 1 - - -@pytest.mark.parametrize( - ("config", "entity_id"), - [ - ( - { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - }, - "binary_sensor.test", - ), - ( - { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - }, - "sensor.test", - ), - ], -) -@patch( - "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] -) -async def test_setup_discovery_mqtt_with_invalid_entity_category( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], - entity_id: str, -) -> None: - """Test set up a discovered sensor item with an invalid entity category.""" - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - assert await mqtt_mock_entry() - - domain = entity_id.split(".")[0] - json_config = json.dumps(config) - async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", json_config) - await hass.async_block_till_done() - assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text - state = hass.states.get(entity_id) - assert state is not None - assert len(events) == 0 - - @patch("homeassistant.components.mqtt.PLATFORMS", []) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol"), @@ -2548,7 +2452,7 @@ async def test_delayed_birth_message( """Test sending birth message does not happen until Home Assistant starts.""" mqtt_mock = await mqtt_mock_entry() - hass.state = CoreState.starting + hass.set_state(CoreState.starting) birth = asyncio.Event() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 61a27c287ac..3e88d4a4335 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,125 +1,23 @@ """The tests for the Legacy Mqtt vacuum platform.""" # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 +# and was removed with HA Core 2024.2.0 +# cleanup is planned with HA Core 2025.2 -from copy import deepcopy import json -from typing import Any from unittest.mock import patch import pytest from homeassistant.components import mqtt, vacuum -from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC -from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum -from homeassistant.components.mqtt.vacuum.schema import services_to_strings -from homeassistant.components.mqtt.vacuum.schema_legacy import ( - ALL_SERVICES, - CONF_BATTERY_LEVEL_TOPIC, - CONF_CHARGING_TOPIC, - CONF_CLEANING_TOPIC, - CONF_DOCKED_TOPIC, - CONF_ERROR_TOPIC, - CONF_FAN_SPEED_TOPIC, - CONF_SUPPORTED_FEATURES, - MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, - SERVICE_TO_STRING, -) -from homeassistant.components.vacuum import ( - ATTR_BATTERY_ICON, - ATTR_BATTERY_LEVEL, - ATTR_FAN_SPEED, - ATTR_FAN_SPEED_LIST, - ATTR_STATUS, - VacuumEntityFeature, -) -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType - -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_update_with_json_attrs_bad_json, - help_test_update_with_json_attrs_not_dict, -) +from homeassistant.helpers.typing import DiscoveryInfoType from tests.common import async_fire_mqtt_message -from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import MqttMockHAClientGenerator -DEFAULT_CONFIG = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - CONF_NAME: "mqtttest", - CONF_COMMAND_TOPIC: "vacuum/command", - mqttvacuum.CONF_SEND_COMMAND_TOPIC: "vacuum/send_command", - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "vacuum/state", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value_json.battery_level }}", - mqttvacuum.CONF_CHARGING_TOPIC: "vacuum/state", - mqttvacuum.CONF_CHARGING_TEMPLATE: "{{ value_json.charging }}", - mqttvacuum.CONF_CLEANING_TOPIC: "vacuum/state", - mqttvacuum.CONF_CLEANING_TEMPLATE: "{{ value_json.cleaning }}", - mqttvacuum.CONF_DOCKED_TOPIC: "vacuum/state", - mqttvacuum.CONF_DOCKED_TEMPLATE: "{{ value_json.docked }}", - mqttvacuum.CONF_ERROR_TOPIC: "vacuum/state", - mqttvacuum.CONF_ERROR_TEMPLATE: "{{ value_json.error }}", - mqttvacuum.CONF_FAN_SPEED_TOPIC: "vacuum/state", - mqttvacuum.CONF_FAN_SPEED_TEMPLATE: "{{ value_json.fan_speed }}", - mqttvacuum.CONF_SET_FAN_SPEED_TOPIC: "vacuum/set_fan_speed", - mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"], - } - } -} - -DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} - -DEFAULT_CONFIG_ALL_SERVICES = help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - }, - ), -) - - -def filter_options(default_config: ConfigType, options: set[str]) -> ConfigType: - """Generate a config from a default config with omitted options.""" - options_base: ConfigType = default_config[mqtt.DOMAIN][vacuum.DOMAIN] - config = deepcopy(default_config) - config[mqtt.DOMAIN][vacuum.DOMAIN] = { - key: value for key, value in options_base.items() if key not in options - } - return config +DEFAULT_CONFIG = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} @pytest.fixture(autouse=True) @@ -130,1009 +28,62 @@ def vacuum_platform_only(): @pytest.mark.parametrize( - ("hass_config", "deprecated"), + ("hass_config", "removed"), [ ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "legacy"}}}, True), - ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}, True), + ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}, False), ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "state"}}}, False), ], ) -async def test_deprecation( +async def test_removed_support_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - deprecated: bool, + removed: bool, ) -> None: - """Test that the depration warning for the legacy schema works.""" + """Test that the removed support validation for the legacy schema works.""" assert await mqtt_mock_entry() entity = hass.states.get("vacuum.test") - assert entity is not None - if deprecated: - assert "Deprecated `legacy` schema detected for MQTT vacuum" in caplog.text + if removed: + assert entity is None + assert ( + "The support for the `legacy` MQTT " + "vacuum schema has been removed" in caplog.text + ) else: - assert "Deprecated `legacy` schema detected for MQTT vacuum" not in caplog.text - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) -async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test that the correct supported features.""" - await mqtt_mock_entry() - entity = hass.states.get("vacuum.mqtttest") - entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) - assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - [ - "turn_on", - "turn_off", - "stop", - "return_home", - "battery", - "status", - "clean_spot", - ] - ) + assert entity is not None @pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test simple commands to the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - await common.async_turn_on(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "turn_on", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_turn_off(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "turn_off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_stop(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with("vacuum/command", "stop", 0, False) - mqtt_mock.async_publish.reset_mock() - - await common.async_clean_spot(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "clean_spot", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_locate(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "locate", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_start_pause(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "start_pause", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_return_to_base(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "return_to_base", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/set_fan_speed", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/send_command", "44 FE 93", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command( - hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" - ) - assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == { - "command": "44 FE 93", - "key": "value", - } - - await common.async_send_command( - hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" - ) - assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == { - "command": "44 FE 93", - "key": "value", - } - - -@pytest.mark.parametrize( - "hass_config", + ("config", "removed"), [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING - ) - }, - ), - ) + ({"name": "test", "schema": "legacy"}, True), + ({"name": "test"}, False), + ({"name": "test", "schema": "state"}, False), ], ) -async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_stop(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_locate(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_start_pause(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_return_to_base(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - -@pytest.mark.parametrize( - "hass_config", - [ - { - "mqtt": { - "vacuum": { - "name": "test", - "schema": "legacy", - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ), - } - } - } - ], -) -async def test_command_without_command_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - await common.async_turn_on(hass, "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - await common.async_set_fan_speed(hass, "low", "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command(hass, "some command", "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.STRING_TO_SERVICE["turn_on"], SERVICE_TO_STRING - ) - }, - ), - ) - ], -) -async def test_attributes_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54, - "cleaning": true, - "docked": false, - "charging": false, - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None - assert state.attributes.get(ATTR_BATTERY_ICON) is None - assert state.attributes.get(ATTR_FAN_SPEED) is None - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54, - "cleaning": true, - "docked": false, - "charging": false, - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_FAN_SPEED) == "max" - - message = """{ - "battery_level": 61, - "docked": true, - "cleaning": false, - "charging": true, - "fan_speed": "min" - }""" - - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 - assert state.attributes.get(ATTR_FAN_SPEED) == "min" - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_battery( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54 - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_cleaning( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await hass.async_block_till_done() - await mqtt_mock_entry() - - message = """{ - "cleaning": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "docked": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "charging": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-outline" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_fan_speed( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED) == "max" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES - VacuumEntityFeature.FAN_SPEED, SERVICE_TO_STRING - ) - }, - ), - ) - ], -) -async def test_status_no_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum. - - If the vacuum doesn't support fan speed, fan speed list should be None. - """ - await mqtt_mock_entry() - - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_error( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "error": "Error1" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_STATUS) == "Error: Error1" - - message = """{ - "error": "" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_STATUS) == "Stopped" - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", - }, - ), - ) - ], -) -async def test_battery_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test that you can use non-default templates for battery_level.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_STATUS) == "Stopped" - - -@pytest.mark.parametrize( - "hass_config", - [ - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CHARGING_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CLEANING_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_DOCKED_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_ERROR_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_FAN_SPEED_TEMPLATE}), - ], -) -async def test_missing_templates( +async def test_removed_support_discovery( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + config: DiscoveryInfoType, + removed: bool, ) -> None: - """Test to make sure missing template is not allowed.""" + """Test that the removed support validation for the legacy schema works.""" assert await mqtt_mock_entry() - assert "some but not all values in the same group of inclusion" in caplog.text + config_payload = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/vacuum/test/config", config_payload) + await hass.async_block_till_done() -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) -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, vacuum.DOMAIN - ) + entity = hass.states.get("vacuum.test") - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - MQTT_LEGACY_VACUUM_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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -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, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -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, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - vacuum.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_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 vacuum per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry, vacuum.DOMAIN) - - -async def test_discovery_removal_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removal of discovered vacuum.""" - data = json.dumps(DEFAULT_CONFIG_2[mqtt.DOMAIN][vacuum.DOMAIN]) - await help_test_discovery_removal( - hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data - ) - - -async def test_discovery_update_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test update of discovered vacuum.""" - config1 = {"name": "Beer", "command_topic": "test_topic"} - config2 = {"name": "Milk", "command_topic": "test_topic"} - await help_test_discovery_update( - hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, config1, config2 - ) - - -async def test_discovery_update_unchanged_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test update of discovered vacuum.""" - data1 = '{ "name": "Beer", "command_topic": "test_topic" }' - with patch( - "homeassistant.components.mqtt.vacuum.schema_legacy.MqttVacuum.discovery_update" - ) as discovery_update: - await help_test_discovery_update_unchanged( - hass, - mqtt_mock_entry, - caplog, - vacuum.DOMAIN, - data1, - discovery_update, + if removed: + assert entity is None + assert ( + "The support for the `legacy` MQTT " + "vacuum schema has been removed" in caplog.text ) - - -@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, vacuum.DOMAIN, data1, data2 - ) - - -async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT vacuum device registry integration.""" - await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT vacuum device registry integration.""" - await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT subscriptions are managed when entity_id is updated.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "battery_level_topic": "test-topic", - "battery_level_template": "{{ value_json.battery_level }}", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - } - } - } - await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - ["test-topic", "avty-topic"], - ) - - -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, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT debug info.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "battery_level_topic": "state-topic", - "battery_level_template": "{{ value_json.battery_level }}", - "command_topic": "command-topic", - "payload_turn_on": "ON", - } - } - } - await help_test_entity_debug_info_message( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - vacuum.SERVICE_TURN_ON, - ) - - -@pytest.mark.parametrize( - ("service", "topic", "parameters", "payload", "template"), - [ - ( - vacuum.SERVICE_TURN_ON, - "command_topic", - None, - "turn_on", - None, - ), - ( - vacuum.SERVICE_CLEAN_SPOT, - "command_topic", - None, - "clean_spot", - None, - ), - ( - vacuum.SERVICE_SET_FAN_SPEED, - "set_fan_speed_topic", - {"fan_speed": "medium"}, - "medium", - None, - ), - ( - vacuum.SERVICE_SEND_COMMAND, - "send_command_topic", - {"command": "custom command"}, - "custom command", - None, - ), - ( - vacuum.SERVICE_TURN_OFF, - "command_topic", - None, - "turn_off", - 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 = vacuum.DOMAIN - config = deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][domain]["supported_features"] = [ - "turn_on", - "turn_off", - "clean_spot", - "fan_speed", - "send_command", - ] - - 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 = vacuum.DOMAIN - config = DEFAULT_CONFIG - await help_test_reloadable(hass, mqtt_client_mock, domain, config) - - -@pytest.mark.parametrize( - ("topic", "value", "attribute", "attribute_value"), - [ - (CONF_BATTERY_LEVEL_TOPIC, '{ "battery_level": 60 }', "battery_level", 60), - (CONF_CHARGING_TOPIC, '{ "charging": true }', "status", "Stopped"), - (CONF_CLEANING_TOPIC, '{ "cleaning": true }', "status", "Cleaning"), - (CONF_DOCKED_TOPIC, '{ "docked": true }', "status", "Docked"), - ( - CONF_ERROR_TOPIC, - '{ "error": "some error" }', - "status", - "Error: some error", - ), - ( - CONF_FAN_SPEED_TOPIC, - '{ "fan_speed": "medium" }', - "fan_speed", - "medium", - ), - ], -) -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.""" - domain = vacuum.DOMAIN - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config[CONF_SUPPORTED_FEATURES] = [ - "turn_on", - "turn_off", - "pause", - "stop", - "return_home", - "battery", - "status", - "locate", - "clean_spot", - "fan_speed", - "send_command", - ] - - await help_test_encoding_subscribable_topics( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - 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 = vacuum.DOMAIN - assert hass.states.get(f"{platform}.mqtttest") - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - "availability_topic": "availability-topic", - "json_attributes_topic": "json-attributes-topic", - }, - ), - ) - ], -) -@pytest.mark.parametrize( - ("topic", "payload1", "payload2"), - [ - ("availability-topic", "online", "offline"), - ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), - ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), - ("vacuum/state", '{"docked": true}', '{"docked": false}'), - ("vacuum/state", '{"cleaning": true}', '{"cleaning": false}'), - ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), - ("vacuum/state", '{"error": "some error"}', '{"error": "other error"}'), - ("vacuum/state", '{"charging": true}', '{"charging": false}'), - ], -) -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) + else: + assert entity is not None diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c5c24c3ae79..d1fa2b72a31 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -303,6 +303,80 @@ async def test_single_color_mode( assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] +@pytest.mark.parametrize("hass_config", [COLOR_MODES_CONFIG]) +async def test_turn_on_with_unknown_color_mode_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup and turn with unknown color_mode in optimistic mode.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # Turn on the light without brightness or color_temp attributes + await common.async_turn_on(hass, "light.test") + state = hass.states.get("light.test") + assert state.attributes.get("color_mode") == light.ColorMode.UNKNOWN + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp") is None + assert state.state == STATE_ON + + # Turn on the light with brightness or color_temp attributes + await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + state = hass.states.get("light.test") + assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp") == 192 + assert state.state == STATE_ON + + +@pytest.mark.parametrize( + "hass_config", + [ + ( + help_custom_config( + light.DOMAIN, + COLOR_MODES_CONFIG, + ({"state_topic": "test_light"},), + ) + ) + ], +) +async def test_controlling_state_with_unknown_color_mode( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup and turn with unknown color_mode in optimistic mode.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # Send `on` state but omit other attributes + async_fire_mqtt_message( + hass, + "test_light", + '{"state": "ON"}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get(light.ATTR_COLOR_TEMP) is None + assert state.attributes.get(light.ATTR_BRIGHTNESS) is None + assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.UNKNOWN + + # Send complete light state + async_fire_mqtt_message( + hass, + "test_light", + '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + + assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 + assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 7a625a2f5f6..751521645a9 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -89,7 +89,6 @@ async def test_availability_with_shared_state_topic( "friendly_name", "device_name", "assert_log", - "issue_events", ), [ ( # default_entity_name_without_device_name @@ -106,7 +105,6 @@ async def test_availability_with_shared_state_topic( DEFAULT_SENSOR_NAME, None, True, - 0, ), ( # default_entity_name_with_device_name { @@ -122,7 +120,6 @@ async def test_availability_with_shared_state_topic( "Test MQTT Sensor", "Test", False, - 0, ), ( # name_follows_device_class { @@ -139,7 +136,6 @@ async def test_availability_with_shared_state_topic( "Test Humidity", "Test", False, - 0, ), ( # name_follows_device_class_without_device_name { @@ -156,7 +152,6 @@ async def test_availability_with_shared_state_topic( "Humidity", None, True, - 0, ), ( # name_overrides_device_class { @@ -174,7 +169,6 @@ async def test_availability_with_shared_state_topic( "Test MySensor", "Test", False, - 0, ), ( # name_set_no_device_name_set { @@ -192,7 +186,6 @@ async def test_availability_with_shared_state_topic( "MySensor", None, True, - 0, ), ( # none_entity_name_with_device_name { @@ -210,7 +203,6 @@ async def test_availability_with_shared_state_topic( "Test", "Test", False, - 0, ), ( # none_entity_name_without_device_name { @@ -228,7 +220,6 @@ async def test_availability_with_shared_state_topic( "mqtt veryunique", None, True, - 0, ), ( # entity_name_and_device_name_the_same { @@ -245,11 +236,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.hello_world", - "Hello world", + "sensor.hello_world_hello_world", + "Hello world Hello world", "Hello world", False, - 1, ), ( # entity_name_startswith_device_name1 { @@ -266,11 +256,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.world_automation", - "World automation", + "sensor.world_world_automation", + "World World automation", "World", False, - 1, ), ( # entity_name_startswith_device_name2 { @@ -287,11 +276,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.world_automation", - "world automation", + "sensor.world_world_automation", + "world world automation", "world", False, - 1, ), ], ids=[ @@ -320,7 +308,6 @@ async def test_default_entity_and_device_name( friendly_name: str, device_name: str | None, assert_log: bool, - issue_events: int, ) -> None: """Test device name setup with and without a device_class set. @@ -328,7 +315,7 @@ async def test_default_entity_and_device_name( """ events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) @@ -349,8 +336,8 @@ async def test_default_entity_and_device_name( "MQTT device information always needs to include a name" in caplog.text ) is assert_log - # Assert that an issues ware registered - assert len(events) == issue_events + # Assert that no issues ware registered + assert len(events) == 0 @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 0476c880b1a..cee7880cf1c 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -460,6 +460,7 @@ async def test_entity_device_info_with_connection( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -476,6 +477,7 @@ async def test_entity_device_info_with_connection( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -496,6 +498,7 @@ async def test_entity_device_info_with_identifier( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -510,6 +513,7 @@ async def test_entity_device_info_with_identifier( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -529,6 +533,7 @@ async def test_entity_device_info_update( "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index d1f265770b8..14153b44d87 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -140,7 +140,7 @@ async def test_waiting_for_client_not_loaded( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client while mqtt entry is not yet loaded.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -199,7 +199,7 @@ async def test_waiting_for_client_entry_fails( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client where mqtt entry is failing.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -227,7 +227,7 @@ async def test_waiting_for_client_setup_fails( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client where mqtt entry is failing during setup.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -254,7 +254,7 @@ async def test_waiting_for_client_timeout( hass: HomeAssistant, ) -> None: """Test waiting for client with timeout.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -273,7 +273,7 @@ async def test_waiting_for_client_with_disabled_entry( hass: HomeAssistant, ) -> None: """Test waiting for client with timeout.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_vacuum.py similarity index 98% rename from tests/components/mqtt/test_state_vacuum.py rename to tests/components/mqtt/test_vacuum.py index 40bd5158280..f48b0b1b375 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -7,13 +7,14 @@ from unittest.mock import patch import pytest from homeassistant.components import mqtt, vacuum +from homeassistant.components.mqtt import vacuum as mqttvacuum from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC -from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum -from homeassistant.components.mqtt.vacuum.const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from homeassistant.components.mqtt.vacuum.schema import services_to_strings -from homeassistant.components.mqtt.vacuum.schema_state import ( +from homeassistant.components.mqtt.vacuum import ( ALL_SERVICES, + CONF_SCHEMA, + MQTT_VACUUM_ATTRIBUTES_BLOCKED, SERVICE_TO_STRING, + services_to_strings, ) from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, @@ -586,7 +587,7 @@ async def test_discovery_update_unchanged_vacuum( """Test update of discovered vacuum.""" data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' with patch( - "homeassistant.components.mqtt.vacuum.schema_state.MqttStateVacuum.discovery_update" + "homeassistant.components.mqtt.vacuum.MqttStateVacuum.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( hass, diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index cd228183c9e..c7bb9d4fcfa 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -58,7 +58,7 @@ async def test_setup_and_stop_waits_for_ha( e_id = "fake.entity" # HA is not running - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await add_statestream(hass, base_topic="pub") await hass.async_block_till_done() diff --git a/tests/components/myuplink/__init__.py b/tests/components/myuplink/__init__.py new file mode 100644 index 00000000000..d5ca745ced0 --- /dev/null +++ b/tests/components/myuplink/__init__.py @@ -0,0 +1 @@ +"""Tests for the myUplink integration.""" diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py new file mode 100644 index 00000000000..ec781af2a1f --- /dev/null +++ b/tests/components/myuplink/test_config_flow.py @@ -0,0 +1,83 @@ +"""Test the myUplink config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.myuplink.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "myuplink", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=READSYSTEM+offline_access" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.myuplink.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 1e0ec5bb944..191721c2e74 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -4,6 +4,10 @@ from unittest.mock import patch from pybotvac.neato import Neato from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.neato.const import NEATO_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -27,12 +31,9 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "neato", - { - "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - }, + assert await setup.async_setup_component(hass, "neato", {}) + await async_import_client_credential( + hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( @@ -101,12 +102,9 @@ async def test_reauth( current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" - assert await setup.async_setup_component( - hass, - "neato", - { - "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - }, + assert await setup.async_setup_component(hass, "neato", {}) + await async_import_client_credential( + hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) MockConfigEntry( diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index e1c3cc187db..a3698cf0e82 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -909,6 +909,8 @@ async def test_thermostat_fan_off( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -956,6 +958,8 @@ async def test_thermostat_fan_on( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -999,6 +1003,8 @@ async def test_thermostat_cool_with_fan( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -1036,6 +1042,8 @@ async def test_thermostat_set_fan( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Turn off fan mode @@ -1098,6 +1106,8 @@ async def test_thermostat_set_fan_when_off( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Fan cannot be turned on when HVAC is off @@ -1143,6 +1153,8 @@ async def test_thermostat_fan_empty( assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 61a7bc2354d..1bb2ab00d32 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -1,17 +1,20 @@ """Common methods used across tests for Netatmo.""" from contextlib import contextmanager import json -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from homeassistant.util.aiohttp import MockRequest -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMockResponse -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - COMMON_RESPONSE = { "user_id": "91763b24c43d3e344f424e8d", "home_id": "91763b24c43d3e344f424e8b", @@ -19,16 +22,36 @@ COMMON_RESPONSE = { "user": {"id": "91763b24c43d3e344f424e8b", "email": "john@doe.com"}, } -TEST_TIME = 1559347200.0 - FAKE_WEBHOOK_ACTIVATION = { "push_type": "webhook_activation", } -DEFAULT_PLATFORMS = ["camera", "climate", "light", "sensor"] + +async def snapshot_platform_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platform: Platform, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot entities and their states.""" + with selected_platforms([platform]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + entity_entries = er.async_entries_for_config_entry( + entity_registry, 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" + ) -async def fake_post_request(*args, **kwargs): +async def fake_post_request(*args: Any, **kwargs: Any): """Return fake data.""" if "endpoint" not in kwargs: return "{}" @@ -62,7 +85,7 @@ async def fake_post_request(*args, **kwargs): ) -async def fake_get_image(*args, **kwargs): +async def fake_get_image(*args: Any, **kwargs: Any) -> bytes | str: """Return fake data.""" if "endpoint" not in kwargs: return "{}" @@ -73,12 +96,7 @@ async def fake_get_image(*args, **kwargs): return b"test stream image bytes" -async def fake_post_request_no_data(*args, **kwargs): - """Fake error during requesting backend data.""" - return "{}" - - -async def simulate_webhook(hass, webhook_id, response): +async def simulate_webhook(hass: HomeAssistant, webhook_id: str, response) -> None: """Simulate a webhook event.""" request = MockRequest( method="POST", @@ -90,7 +108,7 @@ async def simulate_webhook(hass, webhook_id, response): @contextmanager -def selected_platforms(platforms): +def selected_platforms(platforms: list[Platform]) -> AsyncMock: """Restrict loaded platforms to list given.""" with patch( "homeassistant.components.netatmo.data_handler.PLATFORMS", platforms diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index a10030fab08..a21bb8aebe7 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -5,13 +5,35 @@ from unittest.mock import AsyncMock, patch from pyatmo.const import ALL_SCOPES import pytest +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.netatmo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + from .common import fake_get_image, fake_post_request from tests.common import MockConfigEntry +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + @pytest.fixture(name="config_entry") -def mock_config_entry_fixture(hass): +def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Mock a config entry.""" mock_entry = MockConfigEntry( domain="netatmo", @@ -55,7 +77,7 @@ def mock_config_entry_fixture(hass): @pytest.fixture(name="netatmo_auth") -def netatmo_auth(): +def netatmo_auth() -> AsyncMock: """Restrict loaded platforms to list given.""" with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index 6b24a7f8f9d..ccc71dc6b41 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -23,7 +23,8 @@ "12:34:56:00:f1:62", "12:34:56:10:f1:66", "12:34:56:00:e3:9b", - "0009999992" + "0009999992", + "0009999993" ] }, { @@ -174,7 +175,7 @@ "name": "module iDiamant", "setup_date": 1562262465, "room_id": "222452125", - "modules_bridged": ["0009999992"] + "modules_bridged": ["0009999992", "0009999993"] }, { "id": "0009999992", @@ -184,6 +185,14 @@ "room_id": "3688132631", "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "name": "Bubendorff blind", + "setup_date": 1594132017, + "room_id": "3688132631", + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:80:bb:26", "type": "NAMain", @@ -310,7 +319,8 @@ "12:34:56:80:00:c3:69:3c", "12:34:56:00:00:a1:4c:da", "12:34:56:00:01:01:01:a1", - "00:11:22:33:00:11:45:fe" + "00:11:22:33:00:11:45:fe", + "12:34:56:00:01:01:01:b1" ] }, { @@ -466,6 +476,14 @@ "setup_date": 1598367404, "room_id": "1002003001", "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "name": "Centralized ventilation controler", + "setup_date": 1598367504, + "room_id": "1002003001", + "bridge": "12:34:56:80:60:40" } ], "schedules": [ diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 736d70be11c..998cd7155b3 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -139,6 +139,18 @@ "reachable": true, "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "current_position": 0, + "target_position": 0, + "target_position:step": 100, + "firmware_revision": 22, + "rf_strength": 0, + "last_seen": 1671395511, + "reachable": true, + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:00:86:99", "type": "NACamDoorTag", @@ -276,6 +288,16 @@ "power": 0, "reachable": true, "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "firmware_revision": 60, + "last_seen": 1657086949, + "power": 11, + "reachable": true, + "bridge": "12:34:56:80:60:40", + "fan_speed": 1 } ], "rooms": [ diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr new file mode 100644 index 00000000000..bf77abeb151 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_entity[camera.front-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.front', + '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': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:10:b9:0e-DeviceType.NOC', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', + 'friendly_name': 'Front', + 'frontend_stream_type': , + 'id': '12:34:56:10:b9:0e', + 'is_local': False, + 'light_state': None, + 'local_url': None, + 'monitoring': None, + 'motion_detection': True, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,', + }), + 'context': , + 'entity_id': 'camera.front', + 'last_changed': , + 'last_updated': , + 'state': 'streaming', + }) +# --- +# name: test_entity[camera.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.hall', + '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': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:00:f1:62-DeviceType.NACamera', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.hall?token=1caab5c3b3', + 'friendly_name': 'Hall', + 'frontend_stream_type': , + 'id': '12:34:56:00:f1:62', + 'is_local': True, + 'light_state': None, + 'local_url': 'http://192.168.0.123/678460a0d47e5618699fb31169e2b47d', + 'monitoring': None, + 'motion_detection': True, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,', + }), + 'context': , + 'entity_id': 'camera.hall', + 'last_changed': , + 'last_updated': , + 'state': 'streaming', + }) +# --- +# name: test_entity[camera.netatmo_doorbell-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.netatmo_doorbell', + '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': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:10:f1:66-DeviceType.NDB', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.netatmo_doorbell-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.netatmo_doorbell?token=1caab5c3b3', + 'friendly_name': 'Netatmo-Doorbell', + 'id': '12:34:56:10:f1:66', + 'is_local': None, + 'light_state': None, + 'local_url': None, + 'monitoring': None, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,', + }), + 'context': , + 'entity_id': 'camera.netatmo_doorbell', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr new file mode 100644 index 00000000000..db02a4300cd --- /dev/null +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -0,0 +1,385 @@ +# serializer version: 1 +# name: test_entity[climate.bureau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bureau', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bureau', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '222452125-DeviceType.OTM', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.bureau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bureau', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.bureau', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[climate.cocina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.cocina', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cocina', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2940411577-DeviceType.NRV', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.cocina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 27, + 'friendly_name': 'Cocina', + 'heating_power_request': 0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Frost Guard', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 7, + }), + 'context': , + 'entity_id': 'climate.cocina', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.corridor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Corridor', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1002003001-DeviceType.BNS', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.corridor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 22, + 'friendly_name': 'Corridor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Schedule', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22, + }), + 'context': , + 'entity_id': 'climate.corridor', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.entrada-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.entrada', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Entrada', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2833524037-DeviceType.NRV', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.entrada-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 24.5, + 'friendly_name': 'Entrada', + 'heating_power_request': 0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Frost Guard', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 7, + }), + 'context': , + 'entity_id': 'climate.entrada', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.livingroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.livingroom', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Livingroom', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2746182631-DeviceType.NATherm1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.livingroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 19.8, + 'friendly_name': 'Livingroom', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'away', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12, + }), + 'context': , + 'entity_id': 'climate.livingroom', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr new file mode 100644 index 00000000000..c83ae61b4c2 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entity[cover.bubendorff_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.bubendorff_blind', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bubendorff blind', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009999993-DeviceType.NBO', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[cover.bubendorff_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_position': 0, + 'device_class': 'shutter', + 'friendly_name': 'Bubendorff blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bubendorff_blind', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_entity[cover.entrance_blinds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.entrance_blinds', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Entrance Blinds', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009999992-DeviceType.NBR', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[cover.entrance_blinds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_position': 0, + 'device_class': 'shutter', + 'friendly_name': 'Entrance Blinds', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.entrance_blinds', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index f1c54901445..8ce00279b83 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -111,6 +111,7 @@ 'id': '12:34:56:30:d5:d4', 'modules_bridged': list([ '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'room_id': '222452125', @@ -125,6 +126,14 @@ 'setup_date': 1578551339, 'type': 'NBR', }), + dict({ + 'bridge': '12:34:56:30:d5:d4', + 'id': '0009999993', + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1594132017, + 'type': 'NBO', + }), dict({ 'alarm_config': dict({ 'default_alarm': list([ @@ -248,6 +257,7 @@ '12:34:56:00:00:a1:4c:da', '12:34:56:00:01:01:01:a1', '00:11:22:33:00:11:45:fe', + '12:34:56:00:01:01:01:b1', ]), 'name': '**REDACTED**', 'room_id': '1310352496', @@ -408,6 +418,14 @@ 'setup_date': 1598367404, 'type': 'NLFN', }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:01:01:01:b1', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1598367504, + 'type': 'NLLF', + }), ]), 'name': '**REDACTED**', 'persons': list([ @@ -443,6 +461,7 @@ '12:34:56:10:f1:66', '12:34:56:00:e3:9b', '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'type': 'custom', diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr new file mode 100644 index 00000000000..3b94257d983 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_entity[fan.centralized_ventilation_controler-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'slow', + 'fast', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.centralized_ventilation_controler', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Centralized ventilation controler', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:00:01:01:01:b1-DeviceType.NLLF', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.centralized_ventilation_controler-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Centralized ventilation controler', + 'preset_mode': 'slow', + 'preset_modes': list([ + 'slow', + 'fast', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.centralized_ventilation_controler', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr new file mode 100644 index 00000000000..589d888936b --- /dev/null +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -0,0 +1,1065 @@ +# serializer version: 1 +# name: test_devices[netatmo-0009999992] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '0009999992', + ), + }), + 'is_new': False, + 'manufacturer': 'Bubbendorf', + 'model': 'Roller Shutter', + 'name': 'Entrance Blinds', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-0009999993] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '0009999993', + ), + }), + 'is_new': False, + 'manufacturer': 'Bubbendorf', + 'model': 'Orientable Shutter', + 'name': 'Bubendorff blind', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-00:11:22:33:00:11:45:fe] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '00:11:22:33:00:11:45:fe', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': '2 wire light switch/dimmer', + 'name': 'Unknown 00:11:22:33:00:11:45:fe', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-1002003001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'corridor', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '1002003001', + ), + }), + 'is_new': False, + 'manufacturer': 'Smarther', + 'model': 'Smarther with Netatmo', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Corridor', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:00:a1:4c:da] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:00:a1:4c:da', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Energy Meter', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:01:01:01:a1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:01:01:01:a1', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Light switch/dimmer with neutral', + 'name': 'Bathroom light', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#0', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#1', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#2', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#3', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#4', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#5] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#5', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#6] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#6', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#7] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#7', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#8] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#8', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:f1:62] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:f1:62', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Camera', + 'name': 'Hall', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:03:1b:e4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:03:1b:e4', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Anemometer', + 'name': 'Villa Garden', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:10:b9:0e] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:b9:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Camera', + 'name': 'Front', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:10:f1:66] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:f1:66', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Video Doorbell', + 'name': 'Netatmo-Doorbell', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:25:cf:a8] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:25:cf:a8', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Kitchen', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:26:65:14] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:65:14', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Livingroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:26:68:92] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:68:92', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Baby Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:26:69:0c] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:69:0c', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:3e:c5:46] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:3e:c5:46', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Parents Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:00:12:ac:f2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:00:12:ac:f2', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Plug', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:1c:42] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:1c:42', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Module', + 'name': 'Villa Outdoor', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:44:92] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:44:92', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:7e:18] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:7e:18', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bathroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:bb:26] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:bb:26', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Home Weather station', + 'name': 'Villa', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:c1:ea] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:c1:ea', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Rain Gauge', + 'name': 'Villa Rain', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-222452125] + DeviceRegistryEntrySnapshot({ + 'area_id': 'bureau', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '222452125', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'OpenTherm Modulating Thermostat', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Bureau', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-2746182631] + DeviceRegistryEntrySnapshot({ + 'area_id': 'livingroom', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2746182631', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Thermostat', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Livingroom', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-2833524037] + DeviceRegistryEntrySnapshot({ + 'area_id': 'entrada', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2833524037', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Valve', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Entrada', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-2940411577] + DeviceRegistryEntrySnapshot({ + 'area_id': 'cocina', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2940411577', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Valve', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Cocina', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-Home avg] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home avg', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home avg', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-Home max] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home max', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home max', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Thermostat', + 'name': 'MYHOME', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr new file mode 100644 index 00000000000..a116c6a3e08 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_entity[light.bathroom_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathroom light', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:01:01:01:a1-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.bathroom_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'color_mode': None, + 'friendly_name': 'Bathroom light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.bathroom_light', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity[light.front-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.front', + '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': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:10:b9:0e-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Front', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.front', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[light.unknown_00_11_22_33_00_11_45_fe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.unknown_00_11_22_33_00_11_45_fe', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unknown 00:11:22:33:00:11:45:fe', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:11:22:33:00:11:45:fe-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.unknown_00_11_22_33_00_11_45_fe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Unknown 00:11:22:33:00:11:45:fe', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.unknown_00_11_22_33_00_11_45_fe', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_select.ambr b/tests/components/netatmo/snapshots/test_select.ambr new file mode 100644 index 00000000000..44886451b42 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_select.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entity[select.myhome-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Default', + 'Winter', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.myhome', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MYHOME', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '91763b24c43d3e344f424e8b-schedule-select', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[select.myhome-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'MYHOME', + 'options': list([ + 'Default', + 'Winter', + ]), + }), + 'context': , + 'entity_id': 'select.myhome', + 'last_changed': , + 'last_updated': , + 'state': 'Default', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6447f09fdba --- /dev/null +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -0,0 +1,6160 @@ +# serializer version: 1 +# name: test_entity[sensor.baby_bedroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.baby_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Baby Bedroom CO2', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1053', + }) +# --- +# name: test_entity[sensor.baby_bedroom_health-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.baby_bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Baby Bedroom Health', + 'icon': 'mdi:cloud', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'Fine', + }) +# --- +# name: test_entity[sensor.baby_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.baby_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Baby Bedroom Humidity', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_entity[sensor.baby_bedroom_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Baby Bedroom Noise', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Baby Bedroom Pressure', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1021.4', + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_reachability-entry] + 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.baby_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_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.baby_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Baby Bedroom Temperature', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '21.6', + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_wifi-entry] + 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.baby_bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.bedroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Bedroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_health-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.bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bedroom Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Bedroom Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Bedroom Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.bedroom_reachability-entry] + 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.bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.bedroom_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.bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.bedroom_wifi-entry] + 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.bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.bureau_modulate_battery_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': , + 'entity_id': 'sensor.bureau_modulate_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bureau Modulate Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '222452125-12:34:56:20:f5:8c-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.bureau_modulate_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Bureau Modulate Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bureau_modulate_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.cold_water_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cold_water_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cold water Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#8-12:34:56:00:16:0e#8-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.cold_water_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Cold water Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cold_water_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.cold_water_reachability-entry] + 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.cold_water_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Cold water Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#8-12:34:56:00:16:0e#8-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.cold_water_reachability-state] + None +# --- +# name: test_entity[sensor.consumption_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.consumption_meter_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption meter Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.consumption_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Consumption meter Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consumption_meter_power', + 'last_changed': , + 'last_updated': , + 'state': '476', + }) +# --- +# name: test_entity[sensor.consumption_meter_reachability-entry] + 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.consumption_meter_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Consumption meter Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.consumption_meter_reachability-state] + None +# --- +# name: test_entity[sensor.corridor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Corridor Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1002003001-1002003001-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.corridor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Corridor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.corridor_humidity', + 'last_changed': , + 'last_updated': , + 'state': '67', + }) +# --- +# name: test_entity[sensor.ecocompteur_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ecocompteur_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Écocompteur Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e-12:34:56:00:16:0e-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.ecocompteur_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Écocompteur Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ecocompteur_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.ecocompteur_reachability-entry] + 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.ecocompteur_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Écocompteur Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e-12:34:56:00:16:0e-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.ecocompteur_reachability-state] + None +# --- +# name: test_entity[sensor.gas_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#6-12:34:56:00:16:0e#6-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.gas_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Gas Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.gas_reachability-entry] + 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_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Gas Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#6-12:34:56:00:16:0e#6-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.gas_reachability-state] + None +# --- +# name: test_entity[sensor.home_avg_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_angle-state] + None +# --- +# name: test_entity[sensor.home_avg_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_gust_angle-state] + None +# --- +# name: test_entity[sensor.home_avg_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_gust_strength-state] + None +# --- +# name: test_entity[sensor.home_avg_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.home_avg_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Home avg Humidity', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_avg_humidity', + 'last_changed': , + 'last_updated': , + 'state': '63.2', + }) +# --- +# name: test_entity[sensor.home_avg_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Home avg Pressure', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1010.4', + }) +# --- +# name: test_entity[sensor.home_avg_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home avg Rain', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_rain', + 'last_changed': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_entity[sensor.home_avg_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.home_avg_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home avg Rain today', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_entity[sensor.home_avg_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.home_avg_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Home avg Temperature', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_temperature', + 'last_changed': , + 'last_updated': , + 'state': '22.7', + }) +# --- +# name: test_entity[sensor.home_avg_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Home avg Wind Strength', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_entity[sensor.home_max_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_angle-state] + None +# --- +# name: test_entity[sensor.home_max_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_gust_angle-state] + None +# --- +# name: test_entity[sensor.home_max_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_gust_strength-state] + None +# --- +# name: test_entity[sensor.home_max_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.home_max_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Home max Humidity', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_max_humidity', + 'last_changed': , + 'last_updated': , + 'state': '76', + }) +# --- +# name: test_entity[sensor.home_max_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Home max Pressure', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1014.4', + }) +# --- +# name: test_entity[sensor.home_max_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home max Rain', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_rain', + 'last_changed': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_entity[sensor.home_max_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.home_max_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home max Rain today', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '12.322', + }) +# --- +# name: test_entity[sensor.home_max_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Home max Temperature', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.4', + }) +# --- +# name: test_entity[sensor.home_max_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Home max Wind Strength', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_entity[sensor.hot_water_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hot_water_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#7-12:34:56:00:16:0e#7-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.hot_water_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Hot water Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hot_water_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.hot_water_reachability-entry] + 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.hot_water_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Hot water Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#7-12:34:56:00:16:0e#7-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.hot_water_reachability-state] + None +# --- +# name: test_entity[sensor.kitchen_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.kitchen_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Kitchen CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.kitchen_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_health-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.kitchen_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Kitchen Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.kitchen_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.kitchen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Kitchen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Kitchen Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Kitchen Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_pressure_trend-state] + None +# --- +# name: test_entity[sensor.kitchen_reachability-entry] + 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.kitchen_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_reachability-state] + None +# --- +# name: test_entity[sensor.kitchen_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.kitchen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_temperature_trend-state] + None +# --- +# name: test_entity[sensor.kitchen_wifi-entry] + 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.kitchen_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_wifi-state] + None +# --- +# name: test_entity[sensor.line_1_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_1_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 1 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#0-12:34:56:00:16:0e#0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_1_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_1_reachability-entry] + 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.line_1_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 1 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#0-12:34:56:00:16:0e#0-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_1_reachability-state] + None +# --- +# name: test_entity[sensor.line_2_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_2_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 2 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#1-12:34:56:00:16:0e#1-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_2_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_2_reachability-entry] + 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.line_2_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 2 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#1-12:34:56:00:16:0e#1-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_2_reachability-state] + None +# --- +# name: test_entity[sensor.line_3_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_3_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 3 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#2-12:34:56:00:16:0e#2-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 3 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_3_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_3_reachability-entry] + 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.line_3_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 3 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#2-12:34:56:00:16:0e#2-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_3_reachability-state] + None +# --- +# name: test_entity[sensor.line_4_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_4_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 4 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#3-12:34:56:00:16:0e#3-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_4_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 4 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_4_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_4_reachability-entry] + 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.line_4_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 4 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#3-12:34:56:00:16:0e#3-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_4_reachability-state] + None +# --- +# name: test_entity[sensor.line_5_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_5_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 5 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#4-12:34:56:00:16:0e#4-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_5_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 5 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_5_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_5_reachability-entry] + 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.line_5_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 5 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#4-12:34:56:00:16:0e#4-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_5_reachability-state] + None +# --- +# name: test_entity[sensor.livingroom_battery_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': , + 'entity_id': 'sensor.livingroom_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Livingroom Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2746182631-12:34:56:00:01:ae-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.livingroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Livingroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_entity[sensor.livingroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.livingroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Livingroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.livingroom_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_health-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.livingroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Livingroom Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.livingroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.livingroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Livingroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Livingroom Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Livingroom Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.livingroom_reachability-entry] + 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.livingroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_reachability-state] + None +# --- +# name: test_entity[sensor.livingroom_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.livingroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Livingroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.livingroom_wifi-entry] + 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.livingroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_wifi-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.parents_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Parents Bedroom CO2', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '494', + }) +# --- +# name: test_entity[sensor.parents_bedroom_health-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.parents_bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Parents Bedroom Health', + 'icon': 'mdi:cloud', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'Fine', + }) +# --- +# name: test_entity[sensor.parents_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.parents_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Parents Bedroom Humidity', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '63', + }) +# --- +# name: test_entity[sensor.parents_bedroom_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Parents Bedroom Noise', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Parents Bedroom Pressure', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1014.5', + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_reachability-entry] + 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.parents_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_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.parents_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Parents Bedroom Temperature', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_wifi-entry] + 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.parents_bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.prise_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.prise_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Prise Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.prise_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Prise Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.prise_power', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entity[sensor.prise_reachability-entry] + 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.prise_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Prise Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.prise_reachability-state] + None +# --- +# name: test_entity[sensor.total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.total_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#5-12:34:56:00:16:0e#5-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Total Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.total_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.total_reachability-entry] + 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.total_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Total Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#5-12:34:56:00:16:0e#5-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.total_reachability-state] + None +# --- +# name: test_entity[sensor.valve1_battery_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': , + 'entity_id': 'sensor.valve1_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve1 Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2833524037-12:34:56:03:a5:54-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.valve1_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Valve1 Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve1_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.valve2_battery_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': , + 'entity_id': 'sensor.valve2_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve2 Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2940411577-12:34:56:03:a0:ac-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.valve2_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Valve2 Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve2_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.villa_bathroom_battery_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': , + 'entity_id': 'sensor.villa_bathroom_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bathroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Bathroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_entity[sensor.villa_bathroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bathroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_bathroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa Bathroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1930', + }) +# --- +# name: test_entity[sensor.villa_bathroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bathroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bathroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Bathroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_entity[sensor.villa_bathroom_radio-entry] + 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.villa_bathroom_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_radio-state] + None +# --- +# name: test_entity[sensor.villa_bathroom_reachability-entry] + 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.villa_bathroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_reachability-state] + None +# --- +# name: test_entity[sensor.villa_bathroom_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.villa_bathroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Bathroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bathroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_battery_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': , + 'entity_id': 'sensor.villa_bedroom_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bedroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Bedroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_entity[sensor.villa_bedroom_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa Bedroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1076', + }) +# --- +# name: test_entity[sensor.villa_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_entity[sensor.villa_bedroom_radio-entry] + 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.villa_bedroom_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_radio-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_reachability-entry] + 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.villa_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_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.villa_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_co2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa CO2', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_co2', + 'last_changed': , + 'last_updated': , + 'state': '1339', + }) +# --- +# name: test_entity[sensor.villa_garden_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.villa_garden_angle-state] + None +# --- +# name: test_entity[sensor.villa_garden_battery_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': , + 'entity_id': 'sensor.villa_garden_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_garden_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Garden Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_entity[sensor.villa_garden_direction-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.villa_garden_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windangle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Garden Direction', + 'icon': 'mdi:compass-outline', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_direction', + 'last_changed': , + 'last_updated': , + 'state': 'SW', + }) +# --- +# name: test_entity[sensor.villa_garden_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.villa_garden_gust_angle-state] + None +# --- +# name: test_entity[sensor.villa_garden_gust_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-gustangle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_gust_direction-state] + None +# --- +# name: test_entity[sensor.villa_garden_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_garden_gust_strength-state] + None +# --- +# name: test_entity[sensor.villa_garden_radio-entry] + 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.villa_garden_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_radio-state] + None +# --- +# name: test_entity[sensor.villa_garden_reachability-entry] + 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.villa_garden_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_reachability-state] + None +# --- +# name: test_entity[sensor.villa_garden_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_garden_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Villa Garden Wind Strength', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_garden_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_entity[sensor.villa_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Humidity', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_humidity', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_entity[sensor.villa_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Villa Noise', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_noise', + 'last_changed': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_entity[sensor.villa_outdoor_battery_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': , + 'entity_id': 'sensor.villa_outdoor_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_outdoor_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Outdoor Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Outdoor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_radio-entry] + 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.villa_outdoor_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_radio-state] + None +# --- +# name: test_entity[sensor.villa_outdoor_reachability-entry] + 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.villa_outdoor_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_reachability-state] + None +# --- +# name: test_entity[sensor.villa_outdoor_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.villa_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Outdoor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_outdoor_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Villa Pressure', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1026.8', + }) +# --- +# name: test_entity[sensor.villa_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_pressure_trend-state] + None +# --- +# name: test_entity[sensor.villa_rain_battery_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': , + 'entity_id': 'sensor.villa_rain_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_rain_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Rain Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_rain_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_entity[sensor.villa_rain_radio-entry] + 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.villa_rain_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_radio-state] + None +# --- +# name: test_entity[sensor.villa_rain_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_rain_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Villa Rain Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rain', + 'last_changed': , + 'last_updated': , + 'state': '3.7', + }) +# --- +# name: test_entity[sensor.villa_rain_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_rain_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.villa_rain_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_rain_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Villa Rain Rain today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '6.9', + }) +# --- +# name: test_entity[sensor.villa_rain_reachability-entry] + 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.villa_rain_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_reachability-state] + None +# --- +# name: test_entity[sensor.villa_reachability-entry] + 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.villa_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_reachability-state] + None +# --- +# name: test_entity[sensor.villa_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.villa_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Temperature', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_temperature', + 'last_changed': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_entity[sensor.villa_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_wifi-entry] + 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.villa_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_wifi-state] + None +# --- diff --git a/tests/components/netatmo/snapshots/test_switch.ambr b/tests/components/netatmo/snapshots/test_switch.ambr new file mode 100644 index 00000000000..6069bf60c1f --- /dev/null +++ b/tests/components/netatmo/snapshots/test_switch.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_entity[switch.prise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.prise', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-DeviceType.NLP', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[switch.prise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Prise', + }), + 'context': , + 'entity_id': 'switch.prise', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 6dcc11d31ab..e845ca08f06 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -1,10 +1,11 @@ """The tests for Netatmo camera.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch import pyatmo import pytest -import requests_mock +from syrupy import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.camera import STATE_STREAMING @@ -14,21 +15,45 @@ from homeassistant.components.netatmo.const import ( SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, ) -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er from homeassistant.util import dt as dt_util -from .common import fake_post_request, selected_platforms, simulate_webhook +from .common import ( + fake_post_request, + selected_platforms, + simulate_webhook, + snapshot_platform_entities, +) -from tests.common import async_capture_events, async_fire_time_changed +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + await snapshot_platform_entities( + hass, + config_entry, + Platform.CAMERA, + entity_registry, + snapshot, + ) async def test_setup_component_with_webhook( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup with webhook.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -134,10 +159,10 @@ IMAGE_BYTES_FROM_STREAM = b"test stream image bytes" async def test_camera_image_local( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test retrieval or local camera image.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -162,10 +187,10 @@ async def test_camera_image_local( async def test_camera_image_vpn( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test retrieval of remote camera image.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -188,10 +213,10 @@ async def test_camera_image_vpn( async def test_service_set_person_away( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set person as away.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -227,10 +252,10 @@ async def test_service_set_person_away( async def test_service_set_person_away_invalid_person( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set invalid person as away.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -255,10 +280,10 @@ async def test_service_set_person_away_invalid_person( async def test_service_set_persons_home_invalid_person( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set invalid persons as home.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -283,10 +308,10 @@ async def test_service_set_persons_home_invalid_person( async def test_service_set_persons_home( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set persons as home.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -309,10 +334,10 @@ async def test_service_set_persons_home( async def test_service_set_camera_light( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set the outdoor camera light mode.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -341,10 +366,10 @@ async def test_service_set_camera_light( async def test_service_set_camera_light_invalid_type( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set the indoor camera light mode.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -371,11 +396,13 @@ async def test_service_set_camera_light_invalid_type( assert "NACamera does not have a floodlight" in excinfo.value.args[0] -async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> None: +async def test_camera_reconnect_webhook( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test webhook event on camera reconnect.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -427,7 +454,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No async def test_webhook_person_event( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test that person events are handled.""" with selected_platforms(["camera"]): @@ -465,7 +492,9 @@ async def test_webhook_person_event( assert test_netatmo_event -async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_no_devices( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup with no devices.""" fake_post_hits = 0 @@ -495,12 +524,12 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> async def test_camera_image_raises_exception( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Return fake data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 11e2077f859..e4b8c298c26 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -1,8 +1,9 @@ """The tests for the Netatmo climate platform.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from syrupy import SnapshotAssertion from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( @@ -31,19 +32,44 @@ from homeassistant.components.netatmo.const import ( 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.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_WEBHOOK_ID, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er from homeassistant.util import dt as dt_util -from .common import selected_platforms, simulate_webhook +from .common import selected_platforms, simulate_webhook, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.CLIMATE, + entity_registry, + snapshot, + ) async def test_webhook_event_handling_thermostats( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service and webhook event handling with thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -214,10 +240,10 @@ async def test_webhook_event_handling_thermostats( async def test_service_preset_mode_frost_guard_thermostat( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with frost guard preset for thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -287,10 +313,10 @@ async def test_service_preset_mode_frost_guard_thermostat( async def test_service_preset_modes_thermostat( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with preset modes for thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -367,10 +393,10 @@ async def test_service_preset_modes_thermostat( async def test_service_set_temperature_with_end_datetime( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service setting temperature with an end datetime.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -425,10 +451,10 @@ async def test_service_set_temperature_with_end_datetime( async def test_service_set_temperature_with_time_period( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service setting temperature with an end datetime.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -483,10 +509,10 @@ async def test_service_set_temperature_with_time_period( async def test_service_clear_temperature_setting( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service clearing temperature setting.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -564,10 +590,10 @@ async def test_service_clear_temperature_setting( async def test_webhook_event_handling_no_data( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service and webhook event handling with erroneous data.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -618,7 +644,7 @@ async def test_service_schedule_thermostats( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service for selecting Netatmo schedule with thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -671,7 +697,7 @@ async def test_service_preset_mode_with_end_time_thermostats( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service for set preset mode with end datetime for Netatmo thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -740,10 +766,10 @@ async def test_service_preset_mode_with_end_time_thermostats( async def test_service_preset_mode_already_boost_valves( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with boost preset for valves when already in boost mode.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -820,10 +846,10 @@ async def test_service_preset_mode_already_boost_valves( async def test_service_preset_mode_boost_valves( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with boost preset for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -875,7 +901,7 @@ async def test_service_preset_mode_invalid( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service with invalid preset.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -891,10 +917,10 @@ async def test_service_preset_mode_invalid( async def test_valves_service_turn_off( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn off for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -943,10 +969,10 @@ async def test_valves_service_turn_off( async def test_valves_service_turn_on( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -990,10 +1016,10 @@ async def test_valves_service_turn_on( async def test_webhook_home_id_mismatch( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -1030,10 +1056,10 @@ async def test_webhook_home_id_mismatch( async def test_webhook_set_point( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 56d319b1631..afa9ed02645 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyatmo.const import ALL_SCOPES -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( @@ -14,17 +14,15 @@ from homeassistant.components.netatmo.const import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from .conftest import CLIENT_ID + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - VALID_CONFIG = {} @@ -65,14 +63,6 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "netatmo", - { - "netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_USER} @@ -240,14 +230,6 @@ async def test_reauth( current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" - assert await setup.async_setup_component( - hass, - "netatmo", - { - "netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index cf1cca197a4..5a7c33fc6ef 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -1,5 +1,7 @@ """The tests for Netatmo cover.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( ATTR_POSITION, @@ -9,17 +11,37 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.COVER, + entity_registry, + snapshot, + ) async def test_cover_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" - with selected_platforms(["cover"]): + with selected_platforms([Platform.COVER]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 19f83830a4e..2d13e36150d 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.setup import async_setup_component from .common import fake_post_request +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -17,7 +18,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - config_entry, + config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" with patch( diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py new file mode 100644 index 00000000000..72dd579af67 --- /dev/null +++ b/tests/components/netatmo/test_fan.py @@ -0,0 +1,70 @@ +"""The tests for Netatmo fan.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.FAN, + entity_registry, + snapshot, + ) + + +async def test_switch_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.FAN]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + fan_entity = "fan.centralized_ventilation_controler" + + assert hass.states.get(fan_entity).state == "on" + assert hass.states.get(fan_entity).attributes[ATTR_PRESET_MODE] == "slow" + + # Test turning switch on + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity, ATTR_PRESET_MODE: "fast"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:00:01:01:01:b1", + "fan_speed": 2, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 75b1e9e47e6..3e0231579a8 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyatmo.const import ALL_SCOPES import pytest +from syrupy import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.netatmo import DOMAIN -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import CoreState, HomeAssistant +import homeassistant.helpers.device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -54,7 +56,9 @@ FAKE_WEBHOOK = { } -async def test_setup_component(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup and teardown of the netatmo component.""" with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", @@ -86,7 +90,9 @@ async def test_setup_component(hass: HomeAssistant, config_entry) -> None: assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_with_config(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_with_config( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 @@ -127,7 +133,9 @@ async def test_setup_component_with_webhook( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: """Test setup and teardown of the netatmo component with webhook registration.""" - with selected_platforms(["camera", "climate", "light", "sensor"]): + with selected_platforms( + [Platform.CAMERA, Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -155,7 +163,7 @@ async def test_setup_component_with_webhook( async def test_setup_without_https( - hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -182,7 +190,9 @@ async def test_setup_without_https( assert "https and port 443 is required to register the webhook" in caplog.text -async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: +async def test_setup_with_cloud( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test if set up with active cloud subscription.""" await mock_cloud(hass) await hass.async_block_till_done() @@ -296,9 +306,11 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_with_delay(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_with_delay( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup of the netatmo component with delayed startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) with patch( "pyatmo.AbstractAsyncAuth.async_addwebhook", side_effect=AsyncMock() @@ -404,7 +416,9 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(config_entry.entry_id) -async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_invalid_token( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test handling of invalid token.""" async def fake_ensure_valid_token(*args, **kwargs): @@ -449,3 +463,37 @@ async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) + + +async def test_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + netatmo_auth: AsyncMock, +) -> None: + """Test devices are registered.""" + with selected_platforms( + [ + Platform.CAMERA, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, + 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 + ) + + assert device_entries + + for device_entry in device_entries: + identifier = list(device_entry.identifiers)[0] + assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index b6df9191976..1c83f9c6772 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,22 +1,48 @@ """The tests for Netatmo light.""" from unittest.mock import AsyncMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.components.netatmo import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import FAKE_WEBHOOK_ACTIVATION, selected_platforms, simulate_webhook +from .common import ( + FAKE_WEBHOOK_ACTIVATION, + selected_platforms, + simulate_webhook, + snapshot_platform_entities, +) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.LIGHT, + entity_registry, + snapshot, + ) + + async def test_camera_light_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test camera ligiht setup and services.""" with selected_platforms(["light"]): @@ -127,7 +153,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> async def test_light_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" with selected_platforms(["light"]): diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index aebfa23cee9..055ea355b48 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -1,21 +1,50 @@ """The tests for the Netatmo climate platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, SERVICE_SELECT_OPTION +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_WEBHOOK_ID, + SERVICE_SELECT_OPTION, + Platform, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms, simulate_webhook +from .common import selected_platforms, simulate_webhook, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SELECT, + entity_registry, + snapshot, + ) async def test_select_schedule_thermostats( - hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + netatmo_auth: AsyncMock, ) -> None: """Test service for selecting Netatmo schedule with thermostats.""" with selected_platforms(["climate", "select"]): diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index ce35873c3e5..8829e374f29 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -1,18 +1,41 @@ """The tests for the Netatmo sensor platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest +from syrupy import SnapshotAssertion from homeassistant.components.netatmo import sensor +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import TEST_TIME, selected_platforms +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry -async def test_indoor_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SENSOR, + entity_registry, + snapshot, + ) + + +async def test_indoor_sensor( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test indoor sensor setup.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -25,9 +48,11 @@ async def test_indoor_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: +async def test_weather_sensor( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test weather sensor unreachable.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -38,10 +63,10 @@ async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) - async def test_public_weather_sensor( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test public weather sensor setup.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -93,7 +118,7 @@ async def test_public_weather_sensor( ("strength", "expected"), [(50, "Full"), (60, "High"), (80, "Medium"), (90, "Low")], ) -async def test_process_wifi(strength, expected) -> None: +async def test_process_wifi(strength: int, expected: str) -> None: """Test wifi strength translation.""" assert sensor.process_wifi(strength) == expected @@ -102,7 +127,7 @@ async def test_process_wifi(strength, expected) -> None: ("strength", "expected"), [(50, "Full"), (70, "High"), (80, "Medium"), (90, "Low")], ) -async def test_process_rf(strength, expected) -> None: +async def test_process_rf(strength: int, expected: str) -> None: """Test radio strength translation.""" assert sensor.process_rf(strength) == expected @@ -111,7 +136,7 @@ async def test_process_rf(strength, expected) -> None: ("health", "expected"), [(4, "Unhealthy"), (3, "Poor"), (2, "Fair"), (1, "Fine"), (0, "Healthy")], ) -async def test_process_health(health, expected) -> None: +async def test_process_health(health: int, expected: str) -> None: """Test health index translation.""" assert sensor.process_health(health) == expected @@ -182,10 +207,15 @@ async def test_process_health(health, expected) -> None: ], ) async def test_weather_sensor_enabling( - hass: HomeAssistant, config_entry, uid, name, expected, netatmo_auth + hass: HomeAssistant, + config_entry: MockConfigEntry, + uid: str, + name: str, + expected: str, + netatmo_auth: AsyncMock, ) -> None: """Test enabling of by default disabled sensors.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): states_before = len(hass.states.async_all()) assert hass.states.get(f"sensor.{name}") is None @@ -206,12 +236,10 @@ async def test_weather_sensor_enabling( async def test_climate_battery_sensor( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test climate device battery sensor.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms( - ["sensor", "climate"] - ): + with selected_platforms([Platform.CLIMATE, Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index 545a2261e41..f5ea08ec1fa 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,22 +1,44 @@ """The tests for Netatmo switch.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SWITCH, + entity_registry, + snapshot, + ) async def test_switch_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" - with selected_platforms(["switch"]): + with selected_platforms([Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() 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 00000000000..6f3950aaabe --- /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 00000000000..2eb2fff89ef --- /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 00000000000..8d16ff29dfa --- /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 index 8ed43c8c887..660b7dd4fdf 100644 --- a/tests/components/netgear_lte/test_binary_sensor.py +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -1,19 +1,27 @@ """The tests for Netgear LTE binary sensor platform.""" -import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +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 -@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") -async def test_binary_sensors(hass: HomeAssistant) -> None: +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.""" - state = hass.states.get("binary_sensor.netgear_lte_mobile_connected") - assert state.state == STATE_ON - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lte_wire_connected") - assert state.state == STATE_OFF - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lte_roaming") - assert state.state == STATE_OFF + 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_init.py b/tests/components/netgear_lte/test_init.py index 7c48d9d87d2..9d9b43f5a16 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -1,7 +1,10 @@ """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 @@ -26,3 +29,16 @@ async def test_async_setup_entry_not_ready( 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_sensor.py b/tests/components/netgear_lte/test_sensor.py index 8682af9a5c3..37f6538fe6a 100644 --- a/tests/components/netgear_lte/test_sensor.py +++ b/tests/components/netgear_lte/test_sensor.py @@ -1,56 +1,27 @@ """The tests for Netgear LTE sensor platform.""" -import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfInformation, -) +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 -@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") -async def test_sensors(hass: HomeAssistant) -> None: +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.""" - state = hass.states.get("sensor.netgear_lte_cell_id") - assert state.state == "12345678" - state = hass.states.get("sensor.netgear_lte_connection_text") - assert state.state == "4G" - state = hass.states.get("sensor.netgear_lte_connection_type") - assert state.state == "IPv4AndIPv6" - state = hass.states.get("sensor.netgear_lte_current_band") - assert state.state == "LTE B4" - state = hass.states.get("sensor.netgear_lte_current_ps_service_type") - assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lte_radio_quality") - assert state.state == "52" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - state = hass.states.get("sensor.netgear_lte_register_network_display") - assert state.state == "T-Mobile" - state = hass.states.get("sensor.netgear_lte_rx_level") - assert state.state == "-113" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - ) - state = hass.states.get("sensor.netgear_lte_sms") - assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "unread" - state = hass.states.get("sensor.netgear_lte_sms_total") - assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "messages" - state = hass.states.get("sensor.netgear_lte_tx_level") - assert state.state == "4" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - ) - state = hass.states.get("sensor.netgear_lte_upstream") - assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lte_usage") - assert state.state == "40.5" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.MEBIBYTES - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + 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/nexia/test_climate.py b/tests/components/nexia/test_climate.py index 6601e49f597..5553965b418 100644 --- a/tests/components/nexia/test_climate.py +++ b/tests/components/nexia/test_climate.py @@ -29,7 +29,7 @@ async def test_climate_zones(hass: HomeAssistant) -> None: "min_temp": 12.8, "preset_mode": "None", "preset_modes": ["None", "Home", "Away", "Sleep"], - "supported_features": 31, + "supported_features": 415, "target_temp_high": 26.1, "target_temp_low": 17.2, "target_temp_step": 1.0, @@ -61,7 +61,7 @@ async def test_climate_zones(hass: HomeAssistant) -> None: "min_temp": 12.8, "preset_mode": "None", "preset_modes": ["None", "Home", "Away", "Sleep"], - "supported_features": 31, + "supported_features": 415, "target_temp_high": 26.1, "target_temp_low": 17.2, "target_temp_step": 1.0, diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 96393a5139d..95c944449de 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -195,7 +195,7 @@ async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: }, ) config_entry.add_to_hass(hass) - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 4de47b9b844..279ffbfbbaa 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -32,6 +32,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_PLATFORM, UnitOfTemperature, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er @@ -686,6 +687,22 @@ async def test_restore_number_restore_state( 100, 38.0, ), + ( + NumberDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + 50.0, + "13.2", + ), + ( + NumberDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 13.0, + "49.2", + ), ], ) async def test_custom_unit( diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e3cf45708fa..8e20983a791 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -95,8 +95,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + assert result["type"] == "progress" - assert result["type"] == "progress_done" with patch( "pyoctoprintapi.OctoprintClient.get_discovery_info", side_effect=ApiError, @@ -144,8 +145,9 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + assert result["type"] == "progress" - assert result["type"] == "progress_done" with patch( "pyoctoprintapi.OctoprintClient.get_discovery_info", side_effect=Exception, @@ -203,7 +205,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "progress_done" + assert result["type"] == "progress" with patch( "pyoctoprintapi.OctoprintClient.get_server_info", @@ -269,7 +271,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "progress_done" + assert result["type"] == "progress" with patch( "pyoctoprintapi.OctoprintClient.get_server_info", @@ -390,10 +392,11 @@ async def test_failed_auth(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + + assert result["type"] == "progress" - assert result["type"] == "progress_done" result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" assert result["reason"] == "auth_failed" @@ -421,10 +424,11 @@ async def test_failed_auth_unexpected_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + + assert result["type"] == "progress" - assert result["type"] == "progress_done" result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" assert result["reason"] == "auth_failed" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 47568a7d760..b23f693b230 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -232,9 +232,7 @@ async def test_onboarding_user( assert resp.status == 200 tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Validate created areas assert len(area_registry.areas) == 3 @@ -347,9 +345,7 @@ async def test_onboarding_integration( assert const.STEP_INTEGRATION in hass_storage[const.DOMAIN]["data"]["done"] tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Onboarding refresh token and new refresh token user = await hass.auth.async_get_user(hass_admin_user.id) @@ -368,7 +364,7 @@ async def test_onboarding_integration_missing_credential( assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.credential = None client = await hass_client() diff --git a/tests/components/opengarage/conftest.py b/tests/components/opengarage/conftest.py new file mode 100644 index 00000000000..189c3a877ff --- /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 00000000000..b4557a116e8 --- /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/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index 7fa19762ddf..5207ac52f0c 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -9,14 +9,12 @@ from homeassistant import data_entry_flow from homeassistant.components.opensky.const import ( CONF_ALTITUDE, CONF_CONTRIBUTING_USER, - DEFAULT_NAME, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_PASSWORD, CONF_RADIUS, CONF_USERNAME, @@ -59,114 +57,6 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: } -@pytest.mark.parametrize( - ("config", "title", "data", "options"), - [ - ( - {CONF_RADIUS: 10.0}, - DEFAULT_NAME, - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - { - CONF_RADIUS: 10.0, - CONF_NAME: "My home", - }, - "My home", - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - { - CONF_RADIUS: 10.0, - CONF_LATITUDE: 10.0, - CONF_LONGITUDE: -100.0, - }, - DEFAULT_NAME, - { - CONF_LATITUDE: 10.0, - CONF_LONGITUDE: -100.0, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - {CONF_RADIUS: 10.0, CONF_ALTITUDE: 100.0}, - DEFAULT_NAME, - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 100.0, - }, - ), - ], -) -async def test_import_flow( - hass: HomeAssistant, - config: dict[str, Any], - title: str, - data: dict[str, Any], - options: dict[str, Any], -) -> None: - """Test the import flow.""" - with patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == title - assert result["options"] == options - assert result["data"] == data - - -async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: - """Test the import flow when same location already exists.""" - MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_NAME, - data={}, - options={ - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - CONF_RADIUS: 10.0, - CONF_ALTITUDE: 100.0, - }, - ).add_to_hass(hass) - with patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - CONF_RADIUS: 10.0, - CONF_ALTITUDE: 100.0, - }, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - @pytest.mark.parametrize( ("user_input", "error"), [ diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 3429d5eec7e..27c45d1b8ca 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -6,38 +6,16 @@ from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.opensky.const import ( - DOMAIN, EVENT_OPENSKY_ENTRY, EVENT_OPENSKY_EXIT, ) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import get_states_response_fixture from .conftest import ComponentSetup from tests.common import MockConfigEntry, async_fire_time_changed -LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} - - -async def test_legacy_migration(hass: HomeAssistant) -> None: - """Test migration from yaml to config flow.""" - with patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states.json"), - ): - assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - async def test_sensor( hass: HomeAssistant, diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index a30275d3569..c839cb0d06e 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -27,4 +27,34 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) +TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") + TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") + +ROUTER_DISCOVERY_HASS = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\x00\x00\x01\xb1", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", + }, + "interface_index": None, +} diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 75922e99aa0..c03eef8dcb7 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -10,6 +10,7 @@ from . import ( CONFIG_ENTRY_DATA_MULTIPAN, CONFIG_ENTRY_DATA_THREAD, DATASET_CH16, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, ) @@ -30,6 +31,9 @@ async def otbr_config_entry_multipan_fixture(hass): "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests @@ -50,6 +54,9 @@ async def otbr_config_entry_thread_fixture(hass): "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index deb8672b961..1a0216825b4 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -121,9 +121,11 @@ async def test_user_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" @@ -425,9 +427,11 @@ async def test_hassio_discovery_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" @@ -532,9 +536,11 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 1b5c1e8b60a..30569fe5428 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,13 +1,16 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest import python_otbr_api +from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import otbr, thread +from homeassistant.components.thread import discovery from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -21,6 +24,8 @@ from . import ( DATASET_CH16, DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, + ROUTER_DISCOVERY_HASS, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, ) @@ -34,8 +39,19 @@ DATASET_NO_CHANNEL = bytes.fromhex( ) -async def test_import_dataset(hass: HomeAssistant) -> None: +async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test the active dataset is imported at setup.""" + add_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock() + mock_async_zeroconf.async_get_service_info = AsyncMock() + issue_registry = ir.async_get(hass) assert await thread.async_get_preferred_dataset(hass) is None @@ -46,18 +62,49 @@ async def test_import_dataset(hass: HomeAssistant) -> None: title="My OTBR", ) config_entry.add_to_hass(hass) + with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, ): assert await hass.config_entries.async_setup(config_entry.entry_id) + # Wait for Thread router discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Wait for discovery of other routers to time out + await hass.async_block_till_done() + dataset_store = await thread.dataset_store.async_get_store(hass) assert ( list(dataset_store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(dataset_store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await thread.async_get_preferred_dataset(hass) == DATASET_CH16.hex() assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" @@ -91,13 +138,19 @@ async def test_import_share_radio_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, DATASET_CH16.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + DATASET_CH16.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, @@ -128,13 +181,19 @@ async def test_import_share_radio_no_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + dataset.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, @@ -163,13 +222,19 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + dataset.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" @@ -229,6 +294,9 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: mock_api = MagicMock() mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) mock_api.get_border_agent_id = AsyncMock(return_value=TEST_BORDER_AGENT_ID) + mock_api.get_extended_address = AsyncMock( + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS + ) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 8288e7e9f70..52aa792b814 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -8,7 +8,13 @@ from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL, DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID +from . import ( + BASE_URL, + DATASET_CH15, + DATASET_CH16, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, + TEST_BORDER_AGENT_ID, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -37,7 +43,7 @@ async def test_get_info( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "python_otbr_api.OTBR.get_extended_address", - return_value=bytes.fromhex("4EF6C4F3FF750626"), + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() @@ -48,7 +54,7 @@ async def test_get_info( "active_dataset_tlvs": DATASET_CH16.hex().lower(), "channel": 16, "border_agent_id": TEST_BORDER_AGENT_ID.hex(), - "extended_address": "4EF6C4F3FF750626".lower(), + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), } @@ -105,7 +111,10 @@ async def test_create_network( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ) as get_active_dataset_tlvs_mock, patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add: + ) as mock_add, patch( + "homeassistant.components.otbr.util.random.randint", + return_value=0x1234, + ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -113,14 +122,16 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") + python_otbr_api.models.ActiveDataSet( + channel=15, network_name="ha-thread-1234", pan_id=0x1234 + ) ) factory_reset_mock.assert_called_once_with() assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True get_active_dataset_tlvs_mock.assert_called_once() - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None, None) async def test_create_network_no_entry( diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 9ce87d707ff..833c66ab37a 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest -from voluptuous.error import MultipleInvalid +from voluptuous.error import Invalid from homeassistant import config_entries from homeassistant.components.peco.const import DOMAIN @@ -51,7 +51,7 @@ async def test_invalid_county(hass: HomeAssistant) -> None: with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ), pytest.raises(MultipleInvalid): + ), pytest.raises(Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py index ad61ead7bfc..0f303cc0482 100644 --- a/tests/components/permobil/test_config_flow.py +++ b/tests/components/permobil/test_config_flow.py @@ -1,7 +1,11 @@ """Test the MyPermobil config flow.""" from unittest.mock import Mock, patch -from mypermobil import MyPermobilAPIException, MyPermobilClientException +from mypermobil import ( + MyPermobilAPIException, + MyPermobilClientException, + MyPermobilEulaException, +) import pytest from homeassistant import config_entries @@ -67,7 +71,11 @@ async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> 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.""" + """Test email code verification with API error. + + Test the config flow from start to until email code verification + and have the API return API error. + """ my_permobil.request_application_token.side_effect = MyPermobilAPIException # init flow with patch( @@ -105,10 +113,75 @@ async def test_config_flow_incorrect_code( assert result["errors"]["base"] == "invalid_code" +async def test_config_flow_unsigned_eula( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test email code verification with unsigned eula error. + + Test the config flow from start to until email code verification + and have the API return that the eula is unsigned. + """ + my_permobil.request_application_token.side_effect = MyPermobilEulaException + # 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 MyPermobilEulaException + 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"] == "unsigned_eula" + + # Retry to submit the code again, but this time the user has signed the EULA + with patch.object( + my_permobil, + "request_application_token", + return_value=MOCK_TOKEN, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + + # Now the method should not raise an exception, and you can proceed with your assertions + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_DATA + + 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.""" + """Test when the user does not exist in the selected region. + + Test the config flow from start to until the request for email + code and have the API return error because there is not user for + that email. + """ my_permobil.request_application_code.side_effect = MyPermobilAPIException # init flow with patch( @@ -140,7 +213,11 @@ async def test_config_flow_incorrect_region( 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.""" + """Test region request error. + + Test the config flow from start to until the request for regions + and have the API return an error. + """ my_permobil.request_region_names.side_effect = MyPermobilAPIException # init flow # here the request_region_names raises a MyPermobilAPIException @@ -162,7 +239,13 @@ async def test_config_flow_region_request_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.""" + """Test an incorrectly formatted email. + + Test that the email must be formatted correctly. The schema for the + input should already check for this, but since the API does a + separate check that might not overlap 100% with the schema, + this test is still needed. + """ my_permobil.set_email.side_effect = MyPermobilClientException() # init flow # here the set_email raises a MyPermobilClientException diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1866f682b55..a9f91801883 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -30,7 +29,7 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -100,7 +99,7 @@ async def test_valid_invalid_user_ids( async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test set up person with one device tracker.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -160,7 +159,7 @@ async def test_setup_two_trackers( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -248,7 +247,7 @@ async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers, one unavailable.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -303,7 +302,7 @@ async def test_restore_home_state( } state = State("person.tracked_person", "home", attrs) mock_restore_cache(hass, (state,)) - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) mock_component(hass, "recorder") config = { DOMAIN: { @@ -848,30 +847,3 @@ 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/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index d91cb46da0c..de6b4918262 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,4 +1,5 @@ """Test the binary sensor platform of ping.""" +from collections.abc import Generator from datetime import timedelta from unittest.mock import patch @@ -17,6 +18,16 @@ from homeassistant.util.yaml import dump from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files +@pytest.fixture +def entity_registry_enabled_by_default() -> Generator[None, None, None]: + """Test fixture that ensures ping device_tracker entities are enabled in the registry.""" + with patch( + "homeassistant.components.ping.device_tracker.PingDeviceTracker.entity_registry_enabled_default", + return_value=True, + ): + yield + + @pytest.mark.usefixtures("setup_integration") async def test_setup_and_update( hass: HomeAssistant, @@ -125,3 +136,38 @@ async def test_import_delete_known_devices( await hass.async_block_till_done() assert len(remove_device_from_config.mock_calls) == 1 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +async def test_reload_not_triggering_home( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, +): + """Test if reload/restart does not trigger home when device is unavailable.""" + assert hass.states.get("device_tracker.10_10_10_10").state == "home" + + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host("10.10.10.10", 5, []), + ): + # device should be "not_home" after consider_home interval + freezer.tick(timedelta(minutes=5, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.10_10_10_10").state == "not_home" + + # reload config entry + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # device should still be "not_home" after a reload + assert hass.states.get("device_tracker.10_10_10_10").state == "not_home" + + # device should be "home" after the next refresh + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.10_10_10_10").state == "home" diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index a97d312cd54..4d81956eacb 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -159,7 +159,7 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: @pytest.fixture def mock_smile_adam_4() -> Generator[None, MagicMock, None]: """Create a 4th Mock Adam environment for testing exceptions.""" - chosen_env = "adam_jip" + chosen_env = "m_adam_jip" with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True 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 7b570a6cf61..d9bf85b4701 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -13,7 +13,7 @@ "maximum_boiler_temperature": { "lower_bound": 25.0, "resolution": 0.01, - "setpoint": 60.0, + "setpoint": 50.0, "upper_bound": 95.0 }, "model": "Generic heater", @@ -37,8 +37,8 @@ "sensors": { "battery": 99, "temperature": 21.6, - "temperature_difference": 2.3, - "valve_position": 0.0 + "temperature_difference": -0.2, + "valve_position": 100 }, "temperature_offset": { "lower_bound": -2.0, @@ -47,19 +47,25 @@ "upper_bound": 2.0 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" + "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "asleep", + "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", "model": "ThermoTouch", "name": "Anna", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { @@ -79,31 +85,39 @@ "plugwise_notification": false }, "dev_class": "gateway", - "firmware": "3.6.4", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345670001", + "mac_address": "012345679891", "model": "Gateway", "name": "Adam", "regulation_modes": [ - "heating", - "off", - "bleeding_cold", "bleeding_hot", + "bleeding_cold", + "off", + "heating", "cooling" ], + "select_gateway_mode": "full", "select_regulation_mode": "cooling", "sensors": { "outdoor_temperature": 29.65 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], - "control_state": "off", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -111,10 +125,10 @@ "mode": "auto", "model": "Lisa", "name": "Lisa Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 56, + "battery": 38, "setpoint": 23.5, "temperature": 23.9 }, @@ -131,7 +145,7 @@ "upper_bound": 99.9 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" + "zigbee_mac_address": "000D6F000C869B61" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", @@ -150,7 +164,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 145, + "item_count": 147, "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 index f78b4cd38a9..35fe367eb34 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -1,8 +1,8 @@ [ "da224107914542988a88561b4452b0f6", "056ee145a816487eaa69243c3280f8bf", + "e2f4322d57924fa090fbbc48b3a140dc", "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 57259047698..37fc73009d3 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,28 +1,5 @@ { "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": { @@ -41,7 +18,7 @@ "maximum_boiler_temperature": { "lower_bound": 25.0, "resolution": 0.01, - "setpoint": 60.0, + "setpoint": 50.0, "upper_bound": 95.0 }, "model": "Generic heater", @@ -65,8 +42,8 @@ "sensors": { "battery": 99, "temperature": 18.6, - "temperature_difference": 2.3, - "valve_position": 0.0 + "temperature_difference": -0.2, + "valve_position": 100 }, "temperature_offset": { "lower_bound": -2.0, @@ -75,19 +52,25 @@ "upper_bound": 2.0 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" + "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "asleep", + "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", "name": "Anna", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { @@ -107,24 +90,32 @@ "plugwise_notification": false }, "dev_class": "gateway", - "firmware": "3.6.4", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345670001", + "mac_address": "012345679891", "model": "Gateway", "name": "Adam", - "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], + "select_gateway_mode": "full", "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": -1.25 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", @@ -133,10 +124,10 @@ "mode": "auto", "model": "Lisa", "name": "Lisa Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 56, + "battery": 38, "setpoint": 15.0, "temperature": 17.9 }, @@ -153,7 +144,7 @@ "upper_bound": 99.9 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" + "zigbee_mac_address": "000D6F000C869B61" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", @@ -172,7 +163,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 145, + "item_count": 147, "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 index f78b4cd38a9..35fe367eb34 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -1,8 +1,8 @@ [ "da224107914542988a88561b4452b0f6", "056ee145a816487eaa69243c3280f8bf", + "e2f4322d57924fa090fbbc48b3a140dc", "ad4838d7d35c4d6ea796ee12ae5aedf8", "1772a4ea304041adb83f357b751341ff", - "e2f4322d57924fa090fbbc48b3a140dc", "e8ef2a01ed3b4139a53bf749204fe6b4" ] diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json similarity index 98% rename from tests/components/plugwise/fixtures/adam_jip/all_data.json rename to tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 37566e1d39e..915f438c105 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -193,12 +193,14 @@ }, "dev_class": "gateway", "firmware": "3.2.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "9e4433a9d69f40b3aefd15e74395eaec", "mac_address": "012345670001", "model": "Gateway", "name": "Adam", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_gateway_mode": "full", "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": 24.9 @@ -304,7 +306,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 219, + "item_count": 221, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/m_adam_jip/device_list.json similarity index 100% rename from tests/components/plugwise/fixtures/adam_jip/device_list.json rename to tests/components/plugwise/fixtures/m_adam_jip/device_list.json diff --git a/tests/components/plugwise/fixtures/adam_jip/notifications.json b/tests/components/plugwise/fixtures/m_adam_jip/notifications.json similarity index 100% rename from tests/components/plugwise/fixtures/adam_jip/notifications.json rename to tests/components/plugwise/fixtures/m_adam_jip/notifications.json diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f1220a07a2b..86b21af9e8b 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -54,6 +54,9 @@ async def test_adam_select_regulation_mode( Also tests a change in climate _previous mode. """ + state = hass.states.get("select.adam_gateway_mode") + assert state + assert state.state == "full" state = hass.states.get("select.adam_regulation_mode") assert state assert state.state == "cooling" diff --git a/tests/components/plum_lightpad/test_config_flow.py b/tests/components/plum_lightpad/test_config_flow.py index e919932be28..40852094f5b 100644 --- a/tests/components/plum_lightpad/test_config_flow.py +++ b/tests/components/plum_lightpad/test_config_flow.py @@ -22,8 +22,6 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" ), patch( - "homeassistant.components.plum_lightpad.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.plum_lightpad.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -39,7 +37,6 @@ async def test_form(hass: HomeAssistant) -> None: "username": "test-plum-username", "password": "test-plum-password", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -76,7 +73,7 @@ async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: with patch( "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ), patch("homeassistant.components.plum_lightpad.async_setup") as mock_setup, patch( + ), patch( "homeassistant.components.plum_lightpad.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -86,32 +83,4 @@ async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: assert result2["type"] == "abort" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_import(hass: HomeAssistant) -> None: - """Test configuring the flow using configuration.yaml.""" - - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ), patch( - "homeassistant.components.plum_lightpad.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.plum_lightpad.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-plum-username", "password": "test-plum-password"}, - ) - assert result["type"] == "create_entry" - assert result["title"] == "test-plum-username" - assert result["data"] == { - "username": "test-plum-username", - "password": "test-plum-password", - } - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/plum_lightpad/test_init.py b/tests/components/plum_lightpad/test_init.py index 2a3249b7514..66402abf13c 100644 --- a/tests/components/plum_lightpad/test_init.py +++ b/tests/components/plum_lightpad/test_init.py @@ -19,31 +19,6 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant) -> None: assert DOMAIN not in hass.data -async def test_async_setup_imports_from_config(hass: HomeAssistant) -> None: - """Test that specifying config will setup an entry.""" - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ) as mock_loadCloudData, patch( - "homeassistant.components.plum_lightpad.async_setup_entry", - return_value=True, - ) as mock_async_setup_entry: - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - "username": "test-plum-username", - "password": "test-plum-password", - } - }, - ) - await hass.async_block_till_done() - - assert result is True - assert len(mock_loadCloudData.mock_calls) == 1 - assert len(mock_async_setup_entry.mock_calls) == 1 - - async def test_async_setup_entry_sets_up_light(hass: HomeAssistant) -> None: """Test that configuring entry sets up light domain.""" config_entry = MockConfigEntry( diff --git a/tests/components/powerwall/fixtures/batteries.json b/tests/components/powerwall/fixtures/batteries.json new file mode 100644 index 00000000000..fb8d4a97ee4 --- /dev/null +++ b/tests/components/powerwall/fixtures/batteries.json @@ -0,0 +1,32 @@ +[ + { + "PackagePartNumber": "3012170-05-C", + "PackageSerialNumber": "TG0123456789AB", + "energy_charged": 2693355, + "energy_discharged": 2358235, + "nominal_energy_remaining": 14715, + "nominal_full_pack_energy": 14715, + "wobble_detected": false, + "p_out": -100, + "q_out": -1080, + "v_out": 245.70000000000002, + "f_out": 50.037, + "i_out": 0.30000000000000004, + "pinv_grid_state": "Grid_Compliant" + }, + { + "PackagePartNumber": "3012170-05-C", + "PackageSerialNumber": "TG9876543210BA", + "energy_charged": 610483, + "energy_discharged": 509907, + "nominal_energy_remaining": 15137, + "nominal_full_pack_energy": 15137, + "wobble_detected": false, + "p_out": -100, + "q_out": -1090, + "v_out": 245.60000000000002, + "f_out": 50.037, + "i_out": 0.1, + "pinv_grid_state": "Grid_Compliant" + } +] diff --git a/tests/components/powerwall/fixtures/meters_empty.json b/tests/components/powerwall/fixtures/meters_empty.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/components/powerwall/fixtures/meters_empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/powerwall/fixtures/sitemaster.json b/tests/components/powerwall/fixtures/sitemaster.json index edac62d0f7d..90478daa66a 100644 --- a/tests/components/powerwall/fixtures/sitemaster.json +++ b/tests/components/powerwall/fixtures/sitemaster.json @@ -1 +1,6 @@ -{ "connected_to_tesla": true, "running": true, "status": "StatusUp" } +{ + "connected_to_tesla": true, + "power_supply_mode": false, + "running": true, + "status": "StatusUp" +} diff --git a/tests/components/powerwall/fixtures/status.json b/tests/components/powerwall/fixtures/status.json index 058c0fcec49..08a2d0a0ec6 100644 --- a/tests/components/powerwall/fixtures/status.json +++ b/tests/components/powerwall/fixtures/status.json @@ -1,7 +1,10 @@ { - "start_time": "2020-03-10 11:57:25 +0800", - "up_time_seconds": "217h40m57.470801079s", + "commission_count": 0, + "device_type": "hec", + "git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61", "is_new": false, - "version": "1.45.1", - "git_hash": "13bf684a633175f884079ec79f42997080d90310" + "start_time": "2020-10-28 20:14:11 +0800", + "sync_type": "v1", + "up_time_seconds": "17h11m31.214751424s", + "version": "1.50.1 c58c2df3" } diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py index ae6601b0215..10b070a0db7 100644 --- a/tests/components/powerwall/mocks.py +++ b/tests/components/powerwall/mocks.py @@ -1,17 +1,19 @@ """Mocks for powerwall.""" +import asyncio import json import os -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock from tesla_powerwall import ( + BatteryResponse, DeviceType, GridStatus, - MetersAggregates, + MetersAggregatesResponse, Powerwall, - PowerwallStatus, - SiteInfo, - SiteMaster, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, ) from tests.common import load_fixture @@ -19,29 +21,35 @@ from tests.common import load_fixture MOCK_GATEWAY_DIN = "111-0----2-000000000FFA" -async def _mock_powerwall_with_fixtures(hass): +async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> MagicMock: """Mock data used to build powerwall state.""" - meters = await _async_load_json_fixture(hass, "meters.json") - sitemaster = await _async_load_json_fixture(hass, "sitemaster.json") - site_info = await _async_load_json_fixture(hass, "site_info.json") - status = await _async_load_json_fixture(hass, "status.json") - device_type = await _async_load_json_fixture(hass, "device_type.json") + async with asyncio.TaskGroup() as tg: + meters_file = "meters_empty.json" if empty_meters else "meters.json" + meters = tg.create_task(_async_load_json_fixture(hass, meters_file)) + sitemaster = tg.create_task(_async_load_json_fixture(hass, "sitemaster.json")) + site_info = tg.create_task(_async_load_json_fixture(hass, "site_info.json")) + status = tg.create_task(_async_load_json_fixture(hass, "status.json")) + device_type = tg.create_task(_async_load_json_fixture(hass, "device_type.json")) + batteries = tg.create_task(_async_load_json_fixture(hass, "batteries.json")) - return _mock_powerwall_return_value( - site_info=SiteInfo(site_info), + return await _mock_powerwall_return_value( + site_info=SiteInfoResponse.from_dict(site_info.result()), charge=47.34587394586, - sitemaster=SiteMaster(sitemaster), - meters=MetersAggregates(meters), + sitemaster=SiteMasterResponse.from_dict(sitemaster.result()), + meters=MetersAggregatesResponse.from_dict(meters.result()), grid_services_active=True, grid_status=GridStatus.CONNECTED, - status=PowerwallStatus(status), - device_type=DeviceType(device_type["device_type"]), + status=PowerwallStatusResponse.from_dict(status.result()), + device_type=DeviceType(device_type.result()["device_type"]), serial_numbers=["TG0123456789AB", "TG9876543210BA"], backup_reserve_percentage=15.0, + batteries=[ + BatteryResponse.from_dict(battery) for battery in batteries.result() + ], ) -def _mock_powerwall_return_value( +async def _mock_powerwall_return_value( site_info=None, charge=None, sitemaster=None, @@ -52,39 +60,49 @@ def _mock_powerwall_return_value( device_type=None, serial_numbers=None, backup_reserve_percentage=None, + batteries=None, ): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) - powerwall_mock.get_site_info = Mock(return_value=site_info) - powerwall_mock.get_charge = Mock(return_value=charge) - powerwall_mock.get_sitemaster = Mock(return_value=sitemaster) - powerwall_mock.get_meters = Mock(return_value=meters) - powerwall_mock.get_grid_status = Mock(return_value=grid_status) - powerwall_mock.get_status = Mock(return_value=status) - powerwall_mock.get_device_type = Mock(return_value=device_type) - powerwall_mock.get_serial_numbers = Mock(return_value=serial_numbers) - powerwall_mock.get_backup_reserve_percentage = Mock( - return_value=backup_reserve_percentage + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock + + powerwall_mock.get_site_info.return_value = site_info + powerwall_mock.get_charge.return_value = charge + powerwall_mock.get_sitemaster.return_value = sitemaster + powerwall_mock.get_meters.return_value = meters + powerwall_mock.get_grid_status.return_value = grid_status + powerwall_mock.get_status.return_value = status + powerwall_mock.get_device_type.return_value = device_type + powerwall_mock.get_serial_numbers.return_value = serial_numbers + powerwall_mock.get_backup_reserve_percentage.return_value = ( + backup_reserve_percentage ) - powerwall_mock.is_grid_services_active = Mock(return_value=grid_services_active) + powerwall_mock.is_grid_services_active.return_value = grid_services_active + powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN + powerwall_mock.get_batteries.return_value = batteries return powerwall_mock async def _mock_powerwall_site_name(hass, site_name): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock - site_info_resp = SiteInfo(await _async_load_json_fixture(hass, "site_info.json")) - # Sets site_info_resp.site_name to return site_name - site_info_resp.response["site_name"] = site_name - powerwall_mock.get_site_info = Mock(return_value=site_info_resp) - powerwall_mock.get_gateway_din = Mock(return_value=MOCK_GATEWAY_DIN) + site_info_resp = SiteInfoResponse.from_dict( + await _async_load_json_fixture(hass, "site_info.json") + ) + site_info_resp._raw["site_name"] = site_name + site_info_resp.site_name = site_name + powerwall_mock.get_site_info.return_value = site_info_resp + powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN return powerwall_mock -def _mock_powerwall_side_effect(site_info=None): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) - powerwall_mock.get_site_info = Mock(side_effect=site_info) +async def _mock_powerwall_side_effect(site_info=None): + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock + + powerwall_mock.get_site_info.side_effect = site_info return powerwall_mock diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py index b0a62f42368..f24c0e910a2 100644 --- a/tests/components/powerwall/test_binary_sensor.py +++ b/tests/components/powerwall/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch from homeassistant.components.powerwall.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, STATE_ON +from homeassistant.const import CONF_IP_ADDRESS, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from .mocks import _mock_powerwall_with_fixtures @@ -75,3 +75,23 @@ async def test_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: + """Test creation of the binary sensors with empty meters.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.mysite_charging") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 29807fd597b..f9dcc4e1c83 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -1,7 +1,10 @@ """Test the Powerwall config flow.""" +import asyncio +from datetime import timedelta from unittest.mock import MagicMock, patch +import pytest from tesla_powerwall import ( AccessDeniedError, MissingAttributeError, @@ -14,6 +17,7 @@ from homeassistant.components.powerwall.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +import homeassistant.util.dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, @@ -22,7 +26,7 @@ from .mocks import ( _mock_powerwall_with_fixtures, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed VALID_CONFIG = {CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "00GGX"} @@ -36,7 +40,7 @@ async def test_form_source_user(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - mock_powerwall = await _mock_powerwall_site_name(hass, "My site") + mock_powerwall = await _mock_powerwall_site_name(hass, "MySite") with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -52,18 +56,19 @@ async def test_form_source_user(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "My site" + assert result2["title"] == "MySite" assert result2["data"] == VALID_CONFIG assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("exc", (PowerwallUnreachableError, asyncio.TimeoutError)) +async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError) + mock_powerwall = await _mock_powerwall_side_effect(site_info=exc) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -84,7 +89,9 @@ async def test_invalid_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=AccessDeniedError("any")) + mock_powerwall = await _mock_powerwall_side_effect( + site_info=AccessDeniedError("any") + ) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -105,7 +112,7 @@ async def test_form_unknown_exeption(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=ValueError) + mock_powerwall = await _mock_powerwall_side_effect(site_info=ValueError) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -125,7 +132,7 @@ async def test_form_wrong_version(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect( + mock_powerwall = await _mock_powerwall_side_effect( site_info=MissingAttributeError({}, "") ) @@ -286,7 +293,9 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: """Test we can process the discovery from dhcp and we cannot connect.""" - mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError) + mock_powerwall = await _mock_powerwall_side_effect( + site_info=PowerwallUnreachableError + ) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -354,6 +363,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) mock_powerwall = MagicMock(login=MagicMock(side_effect=PowerwallUnreachableError)) + mock_powerwall.__aenter__.return_value = mock_powerwall with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -547,3 +557,49 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4" + + +async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( + hass: HomeAssistant, +) -> None: + """Test a discovery does not update the ip unless the powerwall at the old ip is offline.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + unique_id=MOCK_GATEWAY_DIN, + ) + entry.add_to_hass(hass) + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_powerwall_no_access = await _mock_powerwall_with_fixtures(hass) + mock_powerwall_no_access.login.side_effect = AccessDeniedError("any") + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall_no_access, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Now mock the powerwall to be offline to force + # the discovery flow to probe to see if its online + # which will result in an access denied error, which + # means its still online and we should not update the ip + mock_powerwall.get_meters.side_effect = asyncio.TimeoutError + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.5", + macaddress="AA:BB:CC:DD:EE:FF", + hostname=MOCK_GATEWAY_DIN.lower(), + ), + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4" diff --git a/tests/components/powerwall/test_init.py b/tests/components/powerwall/test_init.py index f9c5ccbbbeb..ed0dc0ebde8 100644 --- a/tests/components/powerwall/test_init.py +++ b/tests/components/powerwall/test_init.py @@ -1,7 +1,7 @@ """Tests for the PowerwallDataManager.""" import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch from tesla_powerwall import AccessDeniedError, LoginResponse @@ -24,12 +24,17 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) # 1. login success on entry setup # 2. login success after reauthentication # 3. login failure after reauthentication - mock_powerwall.login = MagicMock(name="login", return_value=LoginResponse({})) - mock_powerwall.get_charge = MagicMock(name="get_charge", return_value=90.0) - mock_powerwall.is_authenticated = MagicMock( - name="is_authenticated", return_value=True + mock_powerwall.login.return_value = LoginResponse.from_dict( + { + "firstname": "firstname", + "lastname": "lastname", + "token": "token", + "roles": [], + "loginTime": "loginTime", + } ) - mock_powerwall.logout = MagicMock(name="logout") + mock_powerwall.get_charge.return_value = 90.0 + mock_powerwall.is_authenticated.return_value = True config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"} diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index e7772571c86..2de79a6a6dc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -1,6 +1,8 @@ """The sensor tests for the powerwall platform.""" +from datetime import timedelta from unittest.mock import Mock, patch +from tesla_powerwall import MetersAggregatesResponse from tesla_powerwall.error import MissingAttributeError from homeassistant.components.powerwall.const import DOMAIN @@ -11,13 +13,15 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, PERCENTAGE, + STATE_UNKNOWN, ) 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 +import homeassistant.util.dt as dt_util -from .mocks import _mock_powerwall_with_fixtures +from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors( @@ -40,10 +44,10 @@ async def test_sensors( device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( - identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")}, + identifiers={("powerwall", MOCK_GATEWAY_DIN)}, ) assert reg_device.model == "PowerWall 2 (GW1)" - assert reg_device.sw_version == "1.45.1" + assert reg_device.sw_version == "1.50.1 c58c2df3" assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" @@ -118,13 +122,82 @@ async def test_sensors( for key, value in expected_attributes.items(): assert state.attributes[key] == value + mock_powerwall.get_meters.return_value = MetersAggregatesResponse.from_dict({}) + mock_powerwall.get_backup_reserve_percentage.return_value = None + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mysite_load_power").state == STATE_UNKNOWN + assert hass.states.get("sensor.mysite_load_frequency").state == STATE_UNKNOWN + assert hass.states.get("sensor.mysite_backup_reserve").state == STATE_UNKNOWN + + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_capacity").state) + == 14.715 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_voltage").state) + == 245.7 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_frequency").state) == 50.0 + ) + assert float(hass.states.get("sensor.mysite_tg0123456789ab_current").state) == 0.3 + assert int(hass.states.get("sensor.mysite_tg0123456789ab_power").state) == -100 + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_export").state) + == 2358.235 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_import").state) + == 2693.355 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_remaining").state) + == 14.715 + ) + assert ( + str(hass.states.get("sensor.mysite_tg0123456789ab_grid_state").state) + == "grid_compliant" + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_capacity").state) + == 15.137 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_voltage").state) + == 245.6 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_frequency").state) == 50.0 + ) + assert float(hass.states.get("sensor.mysite_tg9876543210ba_current").state) == 0.1 + assert int(hass.states.get("sensor.mysite_tg9876543210ba_power").state) == -100 + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_export").state) + == 509.907 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_import").state) + == 610.483 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_remaining").state) + == 15.137 + ) + assert ( + str(hass.states.get("sensor.mysite_tg9876543210ba_grid_state").state) + == "grid_compliant" + ) + async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None: """Confirm that backup reserve sensor is not added if data is unavailable from the device.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) - mock_powerwall.get_backup_reserve_percentage = Mock( - side_effect=MissingAttributeError(Mock(), "backup_reserve_percent", "operation") + mock_powerwall.get_backup_reserve_percentage.side_effect = MissingAttributeError( + Mock(), "backup_reserve_percent", "operation" ) config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) @@ -140,3 +213,83 @@ async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None: state = hass.states.get("sensor.powerwall_backup_reserve") assert state is None + + +async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: + """Test creation of the sensors with empty meters.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mysite_solar_power") is None + + +async def test_unique_id_migrate( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test we can migrate unique ids of the sensors.""" + device_registry = dr.async_get(hass) + ent_reg = er.async_get(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + old_unique_id = "_".join(sorted(["TG0123456789AB", "TG9876543210BA"])) + new_unique_id = MOCK_GATEWAY_DIN + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("powerwall", old_unique_id)}, + manufacturer="Tesla", + ) + old_mysite_load_power_entity = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + unique_id=f"{old_unique_id}_load_instant_power", + suggested_object_id="mysite_load_power", + config_entry=config_entry, + ) + assert old_mysite_load_power_entity.entity_id == "sensor.mysite_load_power" + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reg_device = device_registry.async_get_device( + identifiers={("powerwall", MOCK_GATEWAY_DIN)}, + ) + old_reg_device = device_registry.async_get_device( + identifiers={("powerwall", old_unique_id)}, + ) + assert old_reg_device is None + assert reg_device is not None + + assert ( + ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{old_unique_id}_load_instant_power" + ) + is None + ) + assert ( + ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{new_unique_id}_load_instant_power" + ) + is not None + ) + + state = hass.states.get("sensor.mysite_load_power") + assert state.state == "1.971" diff --git a/tests/components/powerwall/test_switch.py b/tests/components/powerwall/test_switch.py index 393f89e62fd..e63d6031155 100644 --- a/tests/components/powerwall/test_switch.py +++ b/tests/components/powerwall/test_switch.py @@ -1,5 +1,5 @@ """Test for Powerwall off-grid switch.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from tesla_powerwall import GridStatus, PowerwallError @@ -43,7 +43,7 @@ async def test_entity_registry( ) -> None: """Test powerwall off-grid switch device.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED assert ENTITY_ID in entity_registry.entities @@ -51,7 +51,7 @@ async def test_entity_registry( async def test_initial(hass: HomeAssistant, mock_powerwall) -> None: """Test initial grid status without off grid switch selected.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -60,7 +60,7 @@ async def test_initial(hass: HomeAssistant, mock_powerwall) -> None: async def test_on(hass: HomeAssistant, mock_powerwall) -> None: """Test state once offgrid switch has been turned on.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.ISLANDED) + mock_powerwall.get_grid_status.return_value = GridStatus.ISLANDED await hass.services.async_call( SWITCH_DOMAIN, @@ -76,7 +76,7 @@ async def test_on(hass: HomeAssistant, mock_powerwall) -> None: async def test_off(hass: HomeAssistant, mock_powerwall) -> None: """Test state once offgrid switch has been turned off.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED await hass.services.async_call( SWITCH_DOMAIN, @@ -95,9 +95,7 @@ async def test_exception_on_powerwall_error( """Ensure that an exception in the tesla_powerwall library causes a HomeAssistantError.""" with pytest.raises(HomeAssistantError, match="Setting off-grid operation to"): - mock_powerwall.set_island_mode = Mock( - side_effect=PowerwallError("Mock exception") - ) + mock_powerwall.set_island_mode.side_effect = PowerwallError("Mock exception") await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/proximity/conftest.py b/tests/components/proximity/conftest.py new file mode 100644 index 00000000000..960ab6cf916 --- /dev/null +++ b/tests/components/proximity/conftest.py @@ -0,0 +1,20 @@ +"""Config test for proximity.""" +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(autouse=True) +def config_zones(hass: HomeAssistant): + """Set up zones for test.""" + hass.config.components.add("zone") + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + hass.states.async_set( + "zone.work", + "zoning", + {"name": "Work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + ) diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..68270dc3297 --- /dev/null +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -0,0 +1,108 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'entities': dict({ + 'device_tracker.test1': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 2218752, + 'is_in_ignored_zone': False, + 'name': 'test1', + }), + 'device_tracker.test2': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 4077309, + 'is_in_ignored_zone': False, + 'name': 'test2', + }), + 'device_tracker.test3': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 4077309, + 'is_in_ignored_zone': False, + 'name': 'test3', + }), + }), + 'entity_mapping': dict({ + 'device_tracker.test1': list([ + 'sensor.home_test1_distance', + 'sensor.home_test1_direction_of_travel', + ]), + 'device_tracker.test2': list([ + 'sensor.home_test2_distance', + 'sensor.home_test2_direction_of_travel', + ]), + 'device_tracker.test3': list([ + 'sensor.home_test3_distance', + 'sensor.home_test3_direction_of_travel', + ]), + 'device_tracker.test4': list([ + 'sensor.home_test4_distance', + 'sensor.home_test4_direction_of_travel', + ]), + }), + 'proximity': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 2218752, + 'nearest': 'test1', + }), + 'tracked_states': dict({ + 'device_tracker.test1': dict({ + 'attributes': dict({ + 'friendly_name': 'test1', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test1', + 'state': 'not_home', + }), + 'device_tracker.test2': dict({ + 'attributes': dict({ + 'friendly_name': 'test2', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test2', + 'state': 'not_home', + }), + 'device_tracker.test3': dict({ + 'attributes': dict({ + 'friendly_name': 'test3', + 'latitude': '**REDACTED**', + 'location_name': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test3', + 'state': '**REDACTED**', + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'ignored_zones': list([ + ]), + 'tolerance': 1, + 'tracked_entities': list([ + 'device_tracker.test1', + 'device_tracker.test2', + 'device_tracker.test3', + 'device_tracker.test4', + ]), + 'zone': 'zone.home', + }), + 'disabled_by': None, + 'domain': 'proximity', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'home', + 'unique_id': 'proximity_home', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/proximity/test_config_flow.py b/tests/components/proximity/test_config_flow.py new file mode 100644 index 00000000000..3c94e941227 --- /dev/null +++ b/tests/components/proximity/test_config_flow.py @@ -0,0 +1,251 @@ +"""Test proximity config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "expected_result"), + [ + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ), + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + ), + ], +) +async def test_user_flow( + hass: HomeAssistant, user_input: dict, expected_result: dict +) -> None: + """Test starting a flow by user.""" + 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.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == expected_result + + zone = hass.states.get(user_input[CONF_ZONE]) + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + assert mock_setup_entry.called + + result = await hass.config_entries.options.async_init(mock_config.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_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config.data == { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + } + + +async def test_import_flow(hass: HomeAssistant) -> None: + """Test import of yaml configuration.""" + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + } + + zone = hass.states.get("zone.home") + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_abort_duplicated_entry(hass: HomeAssistant) -> None: + """Test if we abort on duplicate user input data.""" + DATA = { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + } + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data=DATA, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + + +async def test_avoid_duplicated_title(hass: HomeAssistant) -> None: + """Test if we avoid duplicate titles.""" + MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ).add_to_hass(hass) + + MockConfigEntry( + domain=DOMAIN, + title="home 3", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ).add_to_hass(hass) + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test3"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 10, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "home 2" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test4"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 10, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "home 4" + + await hass.async_block_till_done() diff --git a/tests/components/proximity/test_diagnostics.py b/tests/components/proximity/test_diagnostics.py new file mode 100644 index 00000000000..e23d8180672 --- /dev/null +++ b/tests/components/proximity/test_diagnostics.py @@ -0,0 +1,73 @@ +"""Tests for proximity diagnostics platform.""" +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ZONE +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, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 150.1, "longitude": 20.1}, + ) + hass.states.async_set( + "device_tracker.test3", + "my secret address", + { + "friendly_name": "test3", + "latitude": 150.1, + "longitude": 20.1, + "location_name": "my secret address", + }, + ) + + mock_entry = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [ + "device_tracker.test1", + "device_tracker.test2", + "device_tracker.test3", + "device_tracker.test4", + ], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.state == ConfigEntryState.LOADED + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_entry + ) == snapshot(exclude=props("entry_id", "last_changed", "last_updated")) diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index cd96d0d7b81..8fa9e4a1ce1 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -1,87 +1,114 @@ """The tests for the Proximity component.""" -from homeassistant.components.proximity import DOMAIN + +import pytest + +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.components.script import scripts_with_entity +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + CONF_ZONE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) 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 slugify + +from tests.common import MockConfigEntry -async def test_proximities(hass: HomeAssistant) -> None: - """Test a list of proximities.""" - config = { - "proximity": { - "home": { +@pytest.mark.parametrize( + ("friendly_name", "config"), + [ + ( + "home", + { "ignored_zones": ["work"], "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "home_test2": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - }, - "work": { + ), + ( + "work", + { "devices": ["device_tracker.test1"], "tolerance": "1", "zone": "work", }, - } - } + ), + ], +) +async def test_proximities( + hass: HomeAssistant, friendly_name: str, config: dict +) -> None: + """Test a list of proximities.""" + assert await async_setup_component( + hass, DOMAIN, {"proximity": {friendly_name: config}} + ) + await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - - proximities = ["home", "home_test2", "work"] - - for prox in proximities: - state = hass.states.get(f"proximity.{prox}") - assert state.state == "not set" - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" - - hass.states.async_set(f"proximity.{prox}", "0") - await hass.async_block_till_done() - state = hass.states.get(f"proximity.{prox}") - assert state.state == "0" - - -async def test_proximities_setup(hass: HomeAssistant) -> None: - """Test a list of proximities with missing devices.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - }, - "work": {"tolerance": "1", "zone": "work"}, - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - -async def test_proximity(hass: HomeAssistant) -> None: - """Test the proximity.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - state = hass.states.get("proximity.home") + # proximity entity + state = hass.states.get(f"proximity.{friendly_name}") assert state.state == "not set" assert state.attributes.get("nearest") == "not set" assert state.attributes.get("dir_of_travel") == "not set" - - hass.states.async_set("proximity.home", "0") + hass.states.async_set(f"proximity.{friendly_name}", "0") await hass.async_block_till_done() - state = hass.states.get("proximity.home") + state = hass.states.get(f"proximity.{friendly_name}") assert state.state == "0" + # sensor entities + state = hass.states.get(f"sensor.{friendly_name}_nearest_device") + assert state.state == STATE_UNKNOWN + + for device in config["devices"]: + entity_base_name = f"sensor.{friendly_name}_{slugify(device.split('.')[-1])}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNAVAILABLE + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNAVAILABLE + + +async def test_legacy_setup(hass: HomeAssistant) -> None: + """Test legacy setup only on imported entries.""" + config = { + "proximity": { + "home": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + }, + } + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert hass.states.get("proximity.home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="work", + data={ + CONF_ZONE: "zone.work", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_work", + ) + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert not hass.states.get("proximity.work") + async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: """Test for tracker in zone.""" @@ -103,11 +130,319 @@ async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: {"friendly_name": "test1", "latitude": 2.1, "longitude": 1.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.state == "0" assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "arrived" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "0" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "arrived" + + +async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: + """Test for tracker state away.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_awayfurther( + hass: HomeAssistant, config_zones +) -> None: + """Test for tracker state away further.""" + + await hass.async_block_till_done() + + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "away_from" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "away_from" + + +async def test_device_tracker_test1_awaycloser( + hass: HomeAssistant, config_zones +) -> None: + """Test for tracker state away closer.""" + await hass.async_block_till_done() + + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "towards" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "towards" + + +async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: + """Test for tracker in ignored zone.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.state == "not set" + assert state.attributes.get("nearest") == "not set" + assert state.attributes.get("dir_of_travel") == "not set" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: + """Test for tracker with no coordinates.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", "not_home", {"friendly_name": "test1"} + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "not set" + assert state.attributes.get("dir_of_travel") == "not set" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> None: + """Test for tracker states.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": 1000, + "zone": "home", + } + } + }, + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1000001, "longitude": 10.1000001}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1000002, "longitude": 10.1000002}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "stationary" + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "stationary" + async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: """Test for trackers in zone.""" @@ -135,6 +470,8 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: {"friendly_name": "test2", "latitude": 2.1, "longitude": 1.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.state == "0" assert (state.attributes.get("nearest") == "test1, test2") or ( @@ -142,160 +479,22 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: ) assert state.attributes.get("dir_of_travel") == "arrived" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1, test2" -async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: - """Test for tracker state away.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - -async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: - """Test for tracker state away further.""" - - config_zones(hass) - await hass.async_block_till_done() - - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "away_from" - - -async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: - """Test for tracker state away closer.""" - config_zones(hass) - await hass.async_block_till_done() - - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "towards" - - -async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: - """Test for tracker in ignored zone.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.state == "not set" - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" - - -async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: - """Test for tracker with no coordinates.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", "not_home", {"friendly_name": "test1"} - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" + for device in ["test1", "test2"]: + entity_base_name = f"sensor.home_{device}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "0" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "arrived" async def test_device_tracker_test1_awayfurther_than_test2_first_test1( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -328,26 +527,61 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "not_home", {"friendly_name": "test2", "latitude": 40.1, "longitude": 20.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_than_test2_first_test2( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -378,20 +612,56 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( {"friendly_name": "test2", "latitude": 40.1, "longitude": 20.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test2" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test1", "not_home", {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( hass: HomeAssistant, @@ -423,16 +693,33 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_test2_first( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker state.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -489,54 +776,32 @@ async def test_device_tracker_test1_awayfurther_test2_first( hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) await hass.async_block_till_done() + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test2" -async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> None: - """Test for tracker states.""" - assert await async_setup_component( - hass, - DOMAIN, - { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": 1000, - "zone": "home", - } - } - }, - ) + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1000001, "longitude": 10.1000001}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1000002, "longitude": 10.1000002}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "stationary" + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker states.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -568,41 +833,414 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "not_home", {"friendly_name": "test2", "latitude": 10.1, "longitude": 5.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test2" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "989156" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "work", {"friendly_name": "test2", "latitude": 12.6, "longitude": 7.6}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = "sensor.home_test2" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1364567" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "away_from" + + +async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: + """Test for nearest sensors.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1", "device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() -def config_zones(hass): - """Set up zones for test.""" - hass.config.components.add("zone") hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, ) hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 15, "longitude": 8}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 45, "longitude": 22}, + ) + await hass.async_block_till_done() + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "5176058" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "away_from" + + # move the far tracker + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # move the near tracker + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # get unknown distance and direction + hass.states.async_set( + "device_tracker.test1", "not_home", {"friendly_name": "test1"} + ) + hass.states.async_set( + "device_tracker.test2", "not_home", {"friendly_name": "test2"} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_create_deprecated_proximity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue for deprecated proximity entities used in automations and scripts.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": "proximity.home"}, + "action": { + "service": "automation.turn_on", + "target": {"entity_id": "automation.test"}, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": "proximity.home", + "state": "home", + }, + ], + } + } + }, + ) + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, + "work": {"tolerance": "1", "zone": "work"}, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + automation_entities = automations_with_entity(hass, "proximity.home") + assert len(automation_entities) == 1 + assert automation_entities[0] == "automation.test" + + script_entites = scripts_with_entity(hass, "proximity.home") + + assert len(script_entites) == 1 + assert script_entites[0] == "script.test" + assert issue_registry.async_get_issue(DOMAIN, "deprecated_proximity_entity_home") + + assert not issue_registry.async_get_issue( + DOMAIN, "deprecated_proximity_entity_work" + ) + + +async def test_create_removed_tracked_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we create an issue for removed tracked entities.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + t2 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test2" + ) + + hass.states.async_set(t1.entity_id, "not_home") + hass.states.async_set(t2.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, t2.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + sensor_t2 = f"sensor.home_{t2.entity_id.split('.')[-1]}_distance" + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNKNOWN + + hass.states.async_remove(t2.entity_id) + entity_registry.async_remove(t2.entity_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + DOMAIN, f"tracked_entity_removed_{t2.entity_id}" + ) + + +async def test_track_renamed_tracked_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + + hass.states.async_set(t1.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entity_registry.async_update_entity( + t1.entity_id, new_entity_id=f"{t1.entity_id}_renamed" + ) + await hass.async_block_till_done() + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entry = hass.config_entries.async_get_entry(mock_config.entry_id) + assert entry + assert entry.data[CONF_TRACKED_ENTITIES] == [f"{t1.entity_id}_renamed"] + + +async def test_sensor_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1", original_name="Test tracker 1" + ) + hass.states.async_set(t1.entity_id, "not_home") + + hass.states.async_set("device_tracker.test2", "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, "device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = "sensor.home_test_tracker_1_distance" + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + state = hass.states.get(sensor_t1) + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home Test tracker 1 Distance" + + entity = entity_registry.async_get("sensor.home_test2_distance") + assert entity + assert ( + entity.unique_id == f"{mock_config.entry_id}_device_tracker.test2_dist_to_zone" ) diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 4744c065ede..ee7fedee0d5 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.python_script import DOMAIN, FOLDER, execute from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component @@ -136,6 +137,19 @@ raise Exception('boom') assert "Error executing script: boom" in caplog.text +async def test_execute_runtime_error_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + source = """ +raise Exception('boom') + """ + + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == HomeAssistantError + assert "Error executing script (Exception): boom" in str(task.exception()) + + async def test_accessing_async_methods( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -151,6 +165,19 @@ hass.async_stop() assert "Not allowed to access async methods" in caplog.text +async def test_accessing_async_methods_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + source = """ +hass.async_stop() + """ + + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == ServiceValidationError + assert "Not allowed to access async methods" in str(task.exception()) + + async def test_using_complex_structures( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -186,6 +213,21 @@ async def test_accessing_forbidden_methods( assert f"Not allowed to access {name}" in caplog.text +async def test_accessing_forbidden_methods_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + for source, name in { + "hass.stop()": "HomeAssistant.stop", + "dt_util.set_default_time_zone()": "module.set_default_time_zone", + "datetime.non_existing": "module.non_existing", + "time.tzset()": "TimeWrapper.tzset", + }.items(): + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == ServiceValidationError + assert f"Not allowed to access {name}" in str(task.exception()) + + async def test_iterating(hass: HomeAssistant) -> None: """Test compile error logs error.""" source = """ @@ -449,3 +491,108 @@ time.sleep(5) await hass.async_block_till_done() assert caplog.text.count("time.sleep") == 1 + + +async def test_execute_with_output( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test executing a script with a return value.""" + caplog.set_level(logging.WARNING) + + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +output = {"result": f"hello {data.get('name', 'World')}"} + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ): + response = await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) + + assert isinstance(response, dict) + assert len(response) == 1 + assert response["result"] == "hello paulus" + + # No errors logged = good + assert caplog.text == "" + + +async def test_execute_no_output( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test executing a script without a return value.""" + caplog.set_level(logging.WARNING) + + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +no_output = {"result": f"hello {data.get('name', 'World')}"} + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ): + response = await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) + + assert isinstance(response, dict) + assert len(response) == 0 + + # No errors logged = good + assert caplog.text == "" + + +async def test_execute_wrong_output_type(hass: HomeAssistant) -> None: + """Test executing a script without a return value.""" + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +output = f"hello {data.get('name', 'World')}" + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ), pytest.raises(ServiceValidationError): + await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/rabbitair/__init__.py b/tests/components/rabbitair/__init__.py new file mode 100644 index 00000000000..04fae763f56 --- /dev/null +++ b/tests/components/rabbitair/__init__.py @@ -0,0 +1 @@ +"""Tests for the RabbitAir integration.""" diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py new file mode 100644 index 00000000000..75b97d01065 --- /dev/null +++ b/tests/components/rabbitair/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test the RabbitAir config flow.""" +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from ipaddress import ip_address +from unittest.mock import Mock, patch + +import pytest +from rabbitair import Mode, Model, Speed + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.rabbitair.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +TEST_HOST = "1.1.1.1" +TEST_NAME = "abcdef1234_123456789012345678" +TEST_TOKEN = "0123456789abcdef0123456789abcdef" +TEST_MAC = "01:23:45:67:89:AB" +TEST_FIRMWARE = "2.3.17" +TEST_HARDWARE = "1.0.0.4" +TEST_UNIQUE_ID = format_mac(TEST_MAC) +TEST_TITLE = "Rabbit Air" + +ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], + port=9009, + hostname=f"{TEST_NAME}.local.", + type="_rabbitair._udp.local.", + name=f"{TEST_NAME}._rabbitair._udp.local.", + properties={"id": TEST_MAC.replace(":", "")}, +) + + +@pytest.fixture(autouse=True) +def use_mocked_zeroconf(mock_async_zeroconf): + """Mock zeroconf in all tests.""" + + +@pytest.fixture +def rabbitair_connect() -> Generator[None, None, None]: + """Mock connection.""" + with patch("rabbitair.UdpClient.get_info", return_value=get_mock_info()), patch( + "rabbitair.UdpClient.get_state", return_value=get_mock_state() + ): + yield + + +def get_mock_info(mac: str = TEST_MAC) -> Mock: + """Return a mock device info instance.""" + mock_info = Mock() + mock_info.mac = mac + return mock_info + + +def get_mock_state( + model: Model | None = Model.A3, + main_firmware: str | None = TEST_HARDWARE, + power: bool | None = True, + mode: Mode | None = Mode.Auto, + speed: Speed | None = Speed.Low, + wifi_firmware: str | None = TEST_FIRMWARE, +) -> Mock: + """Return a mock device state instance.""" + mock_state = Mock() + mock_state.model = model + mock_state.main_firmware = main_firmware + mock_state.power = power + mock_state.mode = mode + mock_state.speed = speed + mock_state.wifi_firmware = wifi_firmware + return mock_state + + +@pytest.mark.usefixtures("rabbitair_connect") +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 not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "base_value"), + [ + (ValueError, "invalid_access_token"), + (OSError, "invalid_host"), + (asyncio.TimeoutError, "timeout_connect"), + (Exception, "cannot_connect"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, error_type: type[Exception], base_value: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "rabbitair.UdpClient.get_info", + side_effect=error_type, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": base_value} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("rabbitair_connect") +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """Test zeroconf discovery setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/rainforest_raven/__init__.py b/tests/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..0269e4cf0f4 --- /dev/null +++ b/tests/components/rainforest_raven/__init__.py @@ -0,0 +1,44 @@ +"""Tests for the Rainforest RAVEn component.""" + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_MAC + +from .const import ( + DEMAND, + DEVICE_INFO, + DISCOVERY_INFO, + METER_INFO, + METER_LIST, + NETWORK_INFO, + PRICE_CLUSTER, + SUMMATION, +) + +from tests.common import AsyncMock, MockConfigEntry + + +def create_mock_device(): + """Create a mock instance of RAVEnStreamDevice.""" + device = AsyncMock() + + device.__aenter__.return_value = device + device.get_current_price.return_value = PRICE_CLUSTER + device.get_current_summation_delivered.return_value = SUMMATION + device.get_device_info.return_value = DEVICE_INFO + device.get_instantaneous_demand.return_value = DEMAND + device.get_meter_list.return_value = METER_LIST + device.get_meter_info.side_effect = lambda meter: METER_INFO.get(meter) + device.get_network_info.return_value = NETWORK_INFO + + return device + + +def create_mock_entry(no_meters=False): + """Create a mock config entry for a RAVEn device.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: DISCOVERY_INFO.device, + CONF_MAC: [] if no_meters else [METER_INFO[None].meter_mac_id.hex()], + }, + ) diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py new file mode 100644 index 00000000000..7e75440c30d --- /dev/null +++ b/tests/components/rainforest_raven/const.py @@ -0,0 +1,132 @@ +"""Constants for the Rainforest RAVEn tests.""" + +from aioraven.data import ( + CurrentSummationDelivered, + DeviceInfo, + InstantaneousDemand, + MeterInfo, + MeterList, + MeterType, + NetworkInfo, + PriceCluster, +) +from iso4217 import Currency + +from homeassistant.components import usb + +DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/ttyACM0", + pid="0x0003", + vid="0x04B4", + serial_number="1234", + description="RFA-Z105-2 HW2.7.3 EMU-2", + manufacturer="Rainforest Automation, Inc.", +) + + +DEVICE_NAME = usb.human_readable_device_name( + DISCOVERY_INFO.device, + DISCOVERY_INFO.serial_number, + DISCOVERY_INFO.manufacturer, + DISCOVERY_INFO.description, + int(DISCOVERY_INFO.vid, 0), + int(DISCOVERY_INFO.pid, 0), +) + + +DEVICE_INFO = DeviceInfo( + device_mac_id=bytes.fromhex("abcdef0123456789"), + install_code=None, + link_key=None, + fw_version="2.0.0 (7400)", + hw_version="2.7.3", + image_type=None, + manufacturer=DISCOVERY_INFO.manufacturer, + model_id="Z105-2-EMU2-LEDD_JM", + date_code=None, +) + + +METER_LIST = MeterList( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_ids=[ + bytes.fromhex("1234567890abcdef"), + bytes.fromhex("9876543210abcdef"), + ], +) + + +METER_INFO = { + None: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[0]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[1]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[1], + meter_type=MeterType.GAS, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), +} + + +NETWORK_INFO = NetworkInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + coord_mac_id=None, + status=None, + description=None, + status_code=None, + ext_pan_id=None, + channel=13, + short_addr=None, + link_strength=100, +) + + +PRICE_CLUSTER = PriceCluster( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + price="0.10", + currency=Currency.usd, + tier=3, + tier_label="Set by user", + rate_label="Set by user", +) + + +SUMMATION = CurrentSummationDelivered( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + summation_delivered="23456.7890", + summation_received="00000.0000", +) + + +DEMAND = InstantaneousDemand( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + demand="1.2345", +) diff --git a/tests/components/rainforest_raven/test_config_flow.py b/tests/components/rainforest_raven/test_config_flow.py new file mode 100644 index 00000000000..7ec6c52349c --- /dev/null +++ b/tests/components/rainforest_raven/test_config_flow.py @@ -0,0 +1,238 @@ +"""Test Rainforest RAVEn config flow.""" +import asyncio +from unittest.mock import patch + +from aioraven.device import RAVEnConnectionError +import pytest +import serial.tools.list_ports + +from homeassistant import data_entry_flow +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import SOURCE_USB, SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_SOURCE +from homeassistant.core import HomeAssistant + +from . import create_mock_device +from .const import DEVICE_NAME, DISCOVERY_INFO, METER_LIST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.config_flow.RAVEnSerialDevice", + return_value=device, + ): + yield device + + +@pytest.fixture +def mock_device_no_open(mock_device): + """Mock a device which fails to open.""" + mock_device.__aenter__.side_effect = RAVEnConnectionError + mock_device.open.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_comm_error(mock_device): + """Mock a device which fails to read or parse raw data.""" + mock_device.get_meter_list.side_effect = RAVEnConnectionError + mock_device.get_meter_info.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_timeout(mock_device): + """Mock a device which times out when queried.""" + mock_device.get_meter_list.side_effect = asyncio.TimeoutError + mock_device.get_meter_info.side_effect = asyncio.TimeoutError + return mock_device + + +@pytest.fixture +def mock_comports(): + """Mock serial port list.""" + port = serial.tools.list_ports_common.ListPortInfo(DISCOVERY_INFO.device) + port.serial_number = DISCOVERY_INFO.serial_number + port.manufacturer = DISCOVERY_INFO.manufacturer + port.device = DISCOVERY_INFO.device + port.description = DISCOVERY_INFO.description + port.pid = int(DISCOVERY_INFO.pid, 0) + port.vid = int(DISCOVERY_INFO.vid, 0) + comports = [port] + with patch("serial.tools.list_ports.comports", return_value=comports): + yield comports + + +async def test_flow_usb(hass: HomeAssistant, mock_comports, mock_device): + """Test usb flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_usb_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test usb flow connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_usb_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test usb flow connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "timeout_connect" + + +async def test_flow_usb_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test usb flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_user(hass: HomeAssistant, mock_comports, mock_device): + """Test user flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_user_no_available_devices(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: DISCOVERY_INFO.device}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "no_devices_found" + + +async def test_flow_user_in_progress(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "already_in_progress" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} + + +async def test_flow_user_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "timeout_connect"} + + +async def test_flow_user_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} diff --git a/tests/components/rainforest_raven/test_coordinator.py b/tests/components/rainforest_raven/test_coordinator.py new file mode 100644 index 00000000000..6b29c944aeb --- /dev/null +++ b/tests/components/rainforest_raven/test_coordinator.py @@ -0,0 +1,93 @@ +"""Tests for the Rainforest RAVEn data coordinator.""" +from aioraven.device import RAVEnConnectionError +import pytest + +from homeassistant.components.rainforest_raven.coordinator import RAVEnDataCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +async def test_coordinator_device_info(hass: HomeAssistant, mock_device): + """Test reporting device information from the coordinator.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + assert coordinator.device_fw_version is None + assert coordinator.device_hw_version is None + assert coordinator.device_info is None + assert coordinator.device_mac_address is None + assert coordinator.device_manufacturer is None + assert coordinator.device_model is None + assert coordinator.device_name == "RAVEn Device" + + await coordinator.async_config_entry_first_refresh() + + assert coordinator.device_fw_version == "2.0.0 (7400)" + assert coordinator.device_hw_version == "2.7.3" + assert coordinator.device_info + assert coordinator.device_mac_address + assert coordinator.device_manufacturer == "Rainforest Automation, Inc." + assert coordinator.device_model == "Z105-2-EMU2-LEDD_JM" + assert coordinator.device_name == "RAVEn Device" + + +async def test_coordinator_cache_device(hass: HomeAssistant, mock_device): + """Test that the device isn't re-opened for subsequent refreshes.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert mock_device.get_network_info.call_count == 1 + assert mock_device.open.call_count == 1 + + await coordinator.async_refresh() + assert mock_device.get_network_info.call_count == 2 + assert mock_device.open.call_count == 1 + + +async def test_coordinator_device_error_setup(hass: HomeAssistant, mock_device): + """Test handling of a device error during initialization.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.get_network_info.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() + + +async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device): + """Test handling of a device error during an update.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert coordinator.last_update_success is True + + mock_device.get_network_info.side_effect = RAVEnConnectionError + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + +async def test_coordinator_comm_error(hass: HomeAssistant, mock_device): + """Test handling of an error parsing or reading raw device data.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.synchronize.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py new file mode 100644 index 00000000000..639eacadc76 --- /dev/null +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Test the Rainforest Eagle diagnostics.""" +from dataclasses import asdict + +import pytest + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry +from .const import DEMAND, NETWORK_INFO, PRICE_CLUSTER, SUMMATION + +from tests.common import patch +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +@pytest.fixture +async def mock_entry_no_meters(hass: HomeAssistant, mock_device): + """Mock a RAVEn config entry with no meters.""" + mock_entry = create_mock_entry(True) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_entry_diagnostics_no_meters( + hass, hass_client, mock_device, mock_entry_no_meters +): + """Test RAVEn diagnostics before the coordinator has updated.""" + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_entry_no_meters + ) + + config_entry_dict = mock_entry_no_meters.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": {}, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } + + +async def test_entry_diagnostics(hass, hass_client, mock_device, mock_entry): + """Test RAVEn diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry) + + config_entry_dict = mock_entry.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": { + "**REDACTED0**": { + "CurrentSummationDelivered": { + **asdict(SUMMATION), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "InstantaneousDemand": { + **asdict(DEMAND), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "PriceCluster": { + **asdict(PRICE_CLUSTER), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + "currency": { + "__type": str(type(PRICE_CLUSTER.currency)), + "repr": repr(PRICE_CLUSTER.currency), + }, + }, + }, + }, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } diff --git a/tests/components/rainforest_raven/test_init.py b/tests/components/rainforest_raven/test_init.py new file mode 100644 index 00000000000..b99d94f4b43 --- /dev/null +++ b/tests/components/rainforest_raven/test_init.py @@ -0,0 +1,43 @@ +"""Tests for the Rainforest RAVEn component initialisation.""" +import pytest + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_load_unload_entry(hass: HomeAssistant, mock_entry): + """Test load and unload.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/rainforest_raven/test_sensor.py b/tests/components/rainforest_raven/test_sensor.py new file mode 100644 index 00000000000..e637e22ecf9 --- /dev/null +++ b/tests/components/rainforest_raven/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for the Rainforest RAVEn sensors.""" +import pytest + +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_sensors(hass: HomeAssistant, mock_device, mock_entry): + """Test the sensors.""" + assert len(hass.states.async_all()) == 5 + + demand = hass.states.get("sensor.raven_device_meter_power_demand") + assert demand is not None + assert demand.state == "1.2345" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.raven_device_total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "23456.7890" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.raven_device_total_meter_energy_received") + assert received is not None + assert received.state == "00000.0000" + assert received.attributes["unit_of_measurement"] == "kWh" + + price = hass.states.get("sensor.raven_device_meter_price") + assert price is not None + assert price.state == "0.10" + assert price.attributes["unit_of_measurement"] == "USD/kWh" + + signal = hass.states.get("sensor.raven_device_meter_signal_strength") + assert signal is not None + assert signal.state == "100" + assert signal.attributes["unit_of_measurement"] == "%" diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index a9a12d72c41..78af9a64257 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -73,6 +73,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er, recorder as recorder_helper +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry from homeassistant.setup import async_setup_component, setup_component from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads @@ -137,7 +138,7 @@ async def test_shutdown_before_startup_finishes( recorder.CONF_DB_URL: recorder_db_url, recorder.CONF_COMMIT_INTERVAL: 1, } - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass, config)) @@ -168,7 +169,7 @@ async def test_canceled_before_startup_finishes( caplog: pytest.LogCaptureFixture, ) -> None: """Test recorder shuts down when its startup future is canceled out from under it.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass)) await recorder_helper.async_wait_recorder(hass) @@ -192,7 +193,7 @@ async def test_shutdown_closes_connections( ) -> None: """Test shutdown closes connections.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) instance = get_instance(hass) await instance.async_db_ready @@ -219,7 +220,7 @@ async def test_state_gets_saved_when_set_before_start_event( ) -> None: """Test we can record an event when starting with not running.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass)) @@ -1832,6 +1833,15 @@ async def test_database_lock_and_overflow( assert "Database queue backlog reached more than" in caplog.text assert not instance.unlock_database() + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + assert issue is not None + assert "start_time" in issue.translation_placeholders + start_time = issue.translation_placeholders["start_time"] + assert start_time is not None + # Should be in H:M:S format + assert start_time.count(":") == 2 + async def test_database_lock_and_overflow_checks_available_memory( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -1910,6 +1920,15 @@ async def test_database_lock_and_overflow_checks_available_memory( db_events = await instance.async_add_executor_job(_get_db_events) assert len(db_events) >= 2 + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + assert issue is not None + assert "start_time" in issue.translation_placeholders + start_time = issue.translation_placeholders["start_time"] + assert start_time is not None + # Should be in H:M:S format + assert start_time.count(":") == 2 + async def test_database_lock_timeout( recorder_mock: Recorder, hass: HomeAssistant, recorder_db_url: str diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index ede5bc32a6f..db4074a8fdb 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -399,7 +399,7 @@ async def test_schema_migrate( ), patch( "homeassistant.components.recorder.Recorder._process_non_state_changed_event_into_session", ), patch( - "homeassistant.components.recorder.Recorder._pre_process_startup_tasks", + "homeassistant.components.recorder.Recorder._pre_process_startup_events", ): recorder_helper.async_initialize_recorder(hass) hass.async_create_task( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index f5ea8ff1656..639efd0678d 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -77,7 +77,7 @@ def test_from_event_to_db_state_attributes() -> None: dialect = SupportedDialect.MYSQL db_attrs.shared_attrs = StateAttributes.shared_attrs_bytes_from_event( - event, {}, dialect + event, dialect ) assert db_attrs.to_native() == attrs @@ -352,22 +352,6 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - lstate.last_updated = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } - lstate.last_changed = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2020-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } @pytest.mark.parametrize( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 1696c9018b4..2a9260a28a4 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -1424,6 +1424,18 @@ async def test_purge_entities( ) assert states_sensor_kept.count() == 10 + # sensor.keep should remain in the StatesMeta table + states_meta_remain = session.query(StatesMeta).filter( + StatesMeta.entity_id == "sensor.keep" + ) + assert states_meta_remain.count() == 1 + + # sensor.purge_entity should be removed from the StatesMeta table + states_meta_remain = session.query(StatesMeta).filter( + StatesMeta.entity_id == "sensor.purge_entity" + ) + assert states_meta_remain.count() == 0 + _add_purge_records(hass) # Confirm calling service without arguments matches all records (default filter behavior) @@ -1437,6 +1449,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 0 + # The states_meta table should be empty + states_meta_remain = session.query(StatesMeta) + assert states_meta_remain.count() == 0 + async def _add_test_states(hass: HomeAssistant, wait_recording_done: bool = True): """Add multiple states to the db for testing.""" diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 66daced2ca8..583416834fb 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -99,7 +99,7 @@ def test_validate_or_move_away_sqlite_database( async def test_last_run_was_recently_clean( - event_loop, async_setup_recorder_instance: RecorderInstanceGenerator, tmp_path: Path + async_setup_recorder_instance: RecorderInstanceGenerator, tmp_path: Path ) -> None: """Test we can check if the last recorder run was recently clean.""" config = { diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 1f68c9a28d3..0cf6b22dc0c 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -579,3 +579,78 @@ async def test_fix_issue_aborted( assert msg["success"] assert len(msg["result"]["issues"]) == 1 assert msg["result"]["issues"][0] == first_issue + + +@pytest.mark.freeze_time("2022-07-19 07:53:05") +async def test_get_issue_data(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get issue data.""" + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + issues = [ + { + "breaks_in_ha_version": "2022.9", + "data": None, + "domain": "test", + "is_fixable": True, + "issue_id": "issue_1", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com", + "severity": "error", + "translation_key": "abc_123", + "translation_placeholders": {"abc": "123"}, + }, + { + "breaks_in_ha_version": "2022.8", + "data": {"key": "value"}, + "domain": "test", + "is_fixable": False, + "issue_id": "issue_2", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com/abc", + "severity": "other", + "translation_key": "even_worse", + "translation_placeholders": {"def": "456"}, + }, + ] + + for issue in issues: + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + data=issue["data"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=issue["learn_more_url"], + severity=issue["severity"], + translation_key=issue["translation_key"], + translation_placeholders=issue["translation_placeholders"], + ) + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_1"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": None} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_2"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": {"key": "value"}} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "unknown"} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "unknown_issue", + "message": "Issue 'unknown' not found", + } diff --git a/tests/components/rest_command/conftest.py b/tests/components/rest_command/conftest.py new file mode 100644 index 00000000000..1a624b7534f --- /dev/null +++ b/tests/components/rest_command/conftest.py @@ -0,0 +1,42 @@ +"""Fixtures for the trend component tests.""" +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.rest_command import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +ComponentSetup = Callable[[dict[str, Any] | None], Awaitable[None]] + +TEST_URL = "https://example.com/" +TEST_CONFIG = { + "get_test": {"url": TEST_URL, "method": "get"}, + "patch_test": {"url": TEST_URL, "method": "patch"}, + "post_test": {"url": TEST_URL, "method": "post", "payload": "test"}, + "put_test": {"url": TEST_URL, "method": "put"}, + "delete_test": {"url": TEST_URL, "method": "delete"}, + "auth_test": { + "url": TEST_URL, + "method": "get", + "username": "test", + "password": "123456", + }, +} + + +@pytest.fixture(name="setup_component") +async def mock_setup_component( + hass: HomeAssistant, +) -> ComponentSetup: + """Set up the rest_command component.""" + + async def _setup_func(alternative_config: dict[str, Any] | None = None) -> None: + config = alternative_config or TEST_CONFIG + with assert_setup_component(len(config)): + await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + + return _setup_func diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index c43fe84ea8f..b9e5070d457 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -1,354 +1,380 @@ """The tests for the rest command platform.""" import asyncio +import base64 from http import HTTPStatus from unittest.mock import patch import aiohttp +import pytest -import homeassistant.components.rest_command as rc +from homeassistant.components.rest_command import DOMAIN from homeassistant.const import ( CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, SERVICE_RELOAD, ) -from homeassistant.setup import setup_component +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from tests.common import assert_setup_component, get_test_home_assistant +from .conftest import TEST_URL, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker -class TestRestCommandSetup: - """Test the rest command component.""" +async def test_reload(hass: HomeAssistant, setup_component: ComponentSetup) -> None: + """Verify we can reload rest_command integration.""" + await setup_component() - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert hass.services.has_service(DOMAIN, "get_test") + assert not hass.services.has_service(DOMAIN, "new_test") - self.config = {rc.DOMAIN: {"test_get": {"url": "http://example.com/"}}} - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Test setup component.""" - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - def test_setup_component_timeout(self): - """Test setup component timeout.""" - self.config[rc.DOMAIN]["test_get"]["timeout"] = 10 - - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - def test_setup_component_test_service(self): - """Test setup component and check if service exits.""" - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "test_get") - - def test_reload(self): - """Verify we can reload rest_command integration.""" - - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "test_get") - assert not self.hass.services.has_service(rc.DOMAIN, "new_test") - - new_config = { - rc.DOMAIN: { - "new_test": {"url": "https://example.org", "method": "get"}, - } + new_config = { + DOMAIN: { + "new_test": {"url": "https://example.org", "method": "get"}, } - with patch( - "homeassistant.config.load_yaml_config_file", - autospec=True, - return_value=new_config, - ): - self.hass.services.call(rc.DOMAIN, SERVICE_RELOAD, blocking=True) + } + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=new_config, + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) - assert self.hass.services.has_service(rc.DOMAIN, "new_test") - assert not self.hass.services.has_service(rc.DOMAIN, "get_test") + assert hass.services.has_service(DOMAIN, "new_test") + assert not hass.services.has_service(DOMAIN, "get_test") -class TestRestCommandComponent: - """Test the rest command component.""" +async def test_setup_tests( + hass: HomeAssistant, setup_component: ComponentSetup +) -> None: + """Set up test config and test it.""" + await setup_component() - def setup_method(self): - """Set up things to be run when tests are started.""" - self.url = "https://example.com/" - self.config = { - rc.DOMAIN: { - "get_test": {"url": self.url, "method": "get"}, - "patch_test": {"url": self.url, "method": "patch"}, - "post_test": {"url": self.url, "method": "post"}, - "put_test": {"url": self.url, "method": "put"}, - "delete_test": {"url": self.url, "method": "delete"}, + assert hass.services.has_service(DOMAIN, "get_test") + assert hass.services.has_service(DOMAIN, "post_test") + assert hass.services.has_service(DOMAIN, "put_test") + assert hass.services.has_service(DOMAIN, "delete_test") + + +async def test_rest_command_timeout( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with timeout.""" + await setup_component() + + aioclient_mock.get(TEST_URL, exc=asyncio.TimeoutError()) + + with pytest.raises( + HomeAssistantError, + match=r"^Timeout when calling resource 'https://example.com/'$", + ): + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_aiohttp_error( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with aiohttp exception.""" + await setup_component() + + aioclient_mock.get(TEST_URL, exc=aiohttp.ClientError()) + + with pytest.raises( + HomeAssistantError, + match=r"^Client error occurred when calling resource 'https://example.com/'$", + ): + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_http_error( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with status code 400.""" + await setup_component() + + aioclient_mock.get(TEST_URL, status=HTTPStatus.BAD_REQUEST) + + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_auth( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with auth credential.""" + await setup_component() + + aioclient_mock.get(TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, "auth_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_form_data( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with post form data.""" + await setup_component() + + aioclient_mock.post(TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, "post_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == b"test" + + +@pytest.mark.parametrize( + "method", + [ + "get", + "patch", + "post", + "put", + "delete", + ], +) +async def test_rest_command_methods( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + method: str, +): + """Test various http methods.""" + await setup_component() + + aioclient_mock.request(method=method, url=TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, f"{method}_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_headers( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with custom headers and content types.""" + header_config_variations = { + "no_headers_test": {}, + "content_type_test": {"content_type": CONTENT_TYPE_TEXT_PLAIN}, + "headers_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/5.0", } - } - - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_tests(self): - """Set up test config and test it.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "get_test") - assert self.hass.services.has_service(rc.DOMAIN, "post_test") - assert self.hass.services.has_service(rc.DOMAIN, "put_test") - assert self.hass.services.has_service(rc.DOMAIN, "delete_test") - - def test_rest_command_timeout(self, aioclient_mock): - """Call a rest command with timeout.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, exc=asyncio.TimeoutError()) - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_aiohttp_error(self, aioclient_mock): - """Call a rest command with aiohttp exception.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, exc=aiohttp.ClientError()) - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_http_error(self, aioclient_mock): - """Call a rest command with status code 400.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, status=HTTPStatus.BAD_REQUEST) - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_auth(self, aioclient_mock): - """Call a rest command with auth credential.""" - data = {"username": "test", "password": "123456"} - self.config[rc.DOMAIN]["get_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_form_data(self, aioclient_mock): - """Call a rest command with post form data.""" - data = {"payload": "test"} - self.config[rc.DOMAIN]["post_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.post(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "post_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"test" - - def test_rest_command_get(self, aioclient_mock): - """Call a rest command with get.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_delete(self, aioclient_mock): - """Call a rest command with delete.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.delete(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "delete_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_patch(self, aioclient_mock): - """Call a rest command with patch.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["patch_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.patch(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "patch_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_post(self, aioclient_mock): - """Call a rest command with post.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["post_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.post(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "post_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_put(self, aioclient_mock): - """Call a rest command with put.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["put_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.put(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "put_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_headers(self, aioclient_mock): - """Call a rest command with custom headers and content types.""" - header_config_variations = { - rc.DOMAIN: { - "no_headers_test": {}, - "content_type_test": {"content_type": CONTENT_TYPE_TEXT_PLAIN}, - "headers_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - "User-Agent": "Mozilla/5.0", - } - }, - "headers_and_content_type_test": { - "headers": {"Accept": CONTENT_TYPE_JSON}, - "content_type": CONTENT_TYPE_TEXT_PLAIN, - }, - "headers_and_content_type_override_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - aiohttp.hdrs.CONTENT_TYPE: "application/pdf", - }, - "content_type": CONTENT_TYPE_TEXT_PLAIN, - }, - "headers_template_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - "User-Agent": "Mozilla/{{ 3 + 2 }}.0", - } - }, - "headers_and_content_type_override_template_test": { - "headers": { - "Accept": "application/{{ 1 + 1 }}json", - aiohttp.hdrs.CONTENT_TYPE: "application/pdf", - }, - "content_type": "text/json", - }, + }, + "headers_and_content_type_test": { + "headers": {"Accept": CONTENT_TYPE_JSON}, + "content_type": CONTENT_TYPE_TEXT_PLAIN, + }, + "headers_and_content_type_override_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + aiohttp.hdrs.CONTENT_TYPE: "application/pdf", + }, + "content_type": CONTENT_TYPE_TEXT_PLAIN, + }, + "headers_template_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/{{ 3 + 2 }}.0", } - } + }, + "headers_and_content_type_override_template_test": { + "headers": { + "Accept": "application/{{ 1 + 1 }}json", + aiohttp.hdrs.CONTENT_TYPE: "application/pdf", + }, + "content_type": "text/json", + }, + } - # add common parameters - for variation in header_config_variations[rc.DOMAIN].values(): - variation.update( - {"url": self.url, "method": "post", "payload": "test data"} - ) + # add common parameters + for variation in header_config_variations.values(): + variation.update({"url": TEST_URL, "method": "post", "payload": "test data"}) - with assert_setup_component(7): - setup_component(self.hass, rc.DOMAIN, header_config_variations) + await setup_component(header_config_variations) - # provide post request data - aioclient_mock.post(self.url, content=b"success") + # provide post request data + aioclient_mock.post(TEST_URL, content=b"success") - for test_service in [ - "no_headers_test", - "content_type_test", - "headers_test", - "headers_and_content_type_test", - "headers_and_content_type_override_test", - "headers_template_test", - "headers_and_content_type_override_template_test", - ]: - self.hass.services.call(rc.DOMAIN, test_service, {}) + for test_service in [ + "no_headers_test", + "content_type_test", + "headers_test", + "headers_and_content_type_test", + "headers_and_content_type_override_test", + "headers_template_test", + "headers_and_content_type_override_template_test", + ]: + await hass.services.async_call(DOMAIN, test_service, {}, blocking=True) - self.hass.block_till_done() - assert len(aioclient_mock.mock_calls) == 7 + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 7 - # no_headers_test - assert aioclient_mock.mock_calls[0][3] is None + # no_headers_test + assert aioclient_mock.mock_calls[0][3] is None - # content_type_test - assert len(aioclient_mock.mock_calls[1][3]) == 1 - assert ( - aioclient_mock.mock_calls[1][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN + # content_type_test + assert len(aioclient_mock.mock_calls[1][3]) == 1 + assert ( + aioclient_mock.mock_calls[1][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + + # headers_test + assert len(aioclient_mock.mock_calls[2][3]) == 2 + assert aioclient_mock.mock_calls[2][3].get("Accept") == CONTENT_TYPE_JSON + assert aioclient_mock.mock_calls[2][3].get("User-Agent") == "Mozilla/5.0" + + # headers_and_content_type_test + assert len(aioclient_mock.mock_calls[3][3]) == 2 + assert ( + aioclient_mock.mock_calls[3][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + assert aioclient_mock.mock_calls[3][3].get("Accept") == CONTENT_TYPE_JSON + + # headers_and_content_type_override_test + assert len(aioclient_mock.mock_calls[4][3]) == 2 + assert ( + aioclient_mock.mock_calls[4][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + assert aioclient_mock.mock_calls[4][3].get("Accept") == CONTENT_TYPE_JSON + + # headers_template_test + assert len(aioclient_mock.mock_calls[5][3]) == 2 + assert aioclient_mock.mock_calls[5][3].get("Accept") == CONTENT_TYPE_JSON + assert aioclient_mock.mock_calls[5][3].get("User-Agent") == "Mozilla/5.0" + + # headers_and_content_type_override_template_test + assert len(aioclient_mock.mock_calls[6][3]) == 2 + assert aioclient_mock.mock_calls[6][3].get(aiohttp.hdrs.CONTENT_TYPE) == "text/json" + assert aioclient_mock.mock_calls[6][3].get("Accept") == "application/2json" + + +async def test_rest_command_get_response_plaintext( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, text.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, content=b"success", headers={"content-type": "text/plain"} + ) + + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"] == "success" + assert response["status"] == 200 + + +async def test_rest_command_get_response_json( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, json.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, + json={"status": "success", "number": 42}, + headers={"content-type": "application/json"}, + ) + + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"]["status"] == "success" + assert response["content"]["number"] == 42 + assert response["status"] == 200 + + +async def test_rest_command_get_response_malformed_json( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, malformed json.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, + content='{"status": "failure", 42', + headers={"content-type": "application/json"}, + ) + + # No problem without 'return_response' + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + assert not response + + # Throws error when requesting response + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as JSON$", + ): + await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True ) - # headers_test - assert len(aioclient_mock.mock_calls[2][3]) == 2 - assert aioclient_mock.mock_calls[2][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[2][3].get("User-Agent") == "Mozilla/5.0" - # headers_and_content_type_test - assert len(aioclient_mock.mock_calls[3][3]) == 2 - assert ( - aioclient_mock.mock_calls[3][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN +async def test_rest_command_get_response_none( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, other.""" + await setup_component() + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + + aioclient_mock.get( + TEST_URL, + content=png, + headers={"content-type": "text/plain"}, + ) + + # No problem without 'return_response' + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + assert not response + + # Throws Decode error when requesting response + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as text$", + ): + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True ) - assert aioclient_mock.mock_calls[3][3].get("Accept") == CONTENT_TYPE_JSON - # headers_and_content_type_override_test - assert len(aioclient_mock.mock_calls[4][3]) == 2 - assert ( - aioclient_mock.mock_calls[4][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN - ) - assert aioclient_mock.mock_calls[4][3].get("Accept") == CONTENT_TYPE_JSON - - # headers_template_test - assert len(aioclient_mock.mock_calls[5][3]) == 2 - assert aioclient_mock.mock_calls[5][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[5][3].get("User-Agent") == "Mozilla/5.0" - - # headers_and_content_type_override_template_test - assert len(aioclient_mock.mock_calls[6][3]) == 2 - assert ( - aioclient_mock.mock_calls[6][3].get(aiohttp.hdrs.CONTENT_TYPE) - == "text/json" - ) - assert aioclient_mock.mock_calls[6][3].get("Accept") == "application/2json" + assert not response diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 7c1ee331d48..416bd4f71b4 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -193,7 +193,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.test", STATE_ON), State(f"{DOMAIN}.test2", STATE_ON)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, CONFIG, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_cover.py b/tests/components/rflink/test_cover.py index 2f1ff42cc9a..71b3d2067d0 100644 --- a/tests/components/rflink/test_cover.py +++ b/tests/components/rflink/test_cover.py @@ -349,7 +349,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.c1", STATE_OPEN), State(f"{DOMAIN}.c2", STATE_CLOSED)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index 34b918cd3ed..75d2566f336 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -575,7 +575,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py index 07e34c7ebfe..35646d0fd22 100644 --- a/tests/components/rflink/test_switch.py +++ b/tests/components/rflink/test_switch.py @@ -263,7 +263,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.s1", STATE_ON), State(f"{DOMAIN}.s2", STATE_OFF)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/ring/fixtures/chime_devices.json b/tests/components/ring/fixtures/chime_devices.json new file mode 100644 index 00000000000..5c3e60ec655 --- /dev/null +++ b/tests/components/ring/fixtures/chime_devices.json @@ -0,0 +1,35 @@ +{ + "authorized_doorbots": [], + "chimes": [ + { + "address": "123 Main St", + "alerts": { "connection": "online" }, + "description": "Downstairs", + "device_id": "abcdef123", + "do_not_disturb": { "seconds_left": 0 }, + "features": { "ringtones_enabled": true }, + "firmware_version": "1.2.3", + "id": 123456, + "kind": "chime", + "latitude": 12.0, + "longitude": -70.12345, + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Marcelo", + "id": 999999, + "last_name": "Assistant" + }, + "settings": { + "ding_audio_id": null, + "ding_audio_user_id": null, + "motion_audio_id": null, + "motion_audio_user_id": null, + "volume": 2 + }, + "time_zone": "America/New_York" + } + ], + "doorbots": [], + "stickup_cams": [] +} diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 6ad79623a12..8d169002e38 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -60,6 +60,49 @@ async def test_auth_failed_on_setup( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Timeout communicating with API: ", + ), + ( + RingError, + "Error communicating with API: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_setup( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test auth failure on setup entry.""" + mock_config_entry.add_to_hass(hass) + with patch( + "ring_doorbell.Ring.update_data", + side_effect=error_type, + ): + 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 + + assert [ + record.message + for record in caplog.records + if record.levelname == "DEBUG" + and record.name == "homeassistant.config_entries" + and log_msg in record.message + and DOMAIN in record.message + ] + + async def test_auth_failure_on_global_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker, @@ -78,8 +121,11 @@ async def test_auth_failure_on_global_update( 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 "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -91,7 +137,7 @@ async def test_auth_failure_on_device_update( mock_config_entry: MockConfigEntry, caplog, ) -> None: - """Test authentication failure on global data update.""" + """Test authentication failure on device 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() @@ -103,8 +149,11 @@ async def test_auth_failure_on_device_update( 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 "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -115,11 +164,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Time out fetching Ring device data", + "Error fetching devices data: Timeout communicating with API: ", ), ( RingError, - "Error fetching Ring device data: ", + "Error fetching devices data: Error communicating with API: ", ), ], ids=["timeout-error", "other-error"], @@ -145,7 +194,7 @@ async def test_error_on_global_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] @@ -156,11 +205,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Time out fetching Ring history data for device aacdef123", + "Error fetching devices data: Timeout communicating with API for device Front: ", ), ( RingError, - "Error fetching Ring history data for device aacdef123: ", + "Error fetching devices data: Error communicating with API for device Front: ", ), ], ids=["timeout-error", "other-error"], @@ -186,6 +235,6 @@ async def test_error_on_device_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 5c9a6ecacf7..5fd50f69c13 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,10 +1,17 @@ """The tests for the Ring sensor platform.""" +import logging + +from freezegun.api import FrozenDateTimeFactory import requests_mock +from homeassistant.components.ring.const import SCAN_INTERVAL +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .common import setup_platform +from tests.common import async_fire_time_changed, load_fixture + WIFI_ENABLED = False @@ -48,3 +55,27 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker) ) assert front_door_wifi_signal_strength_state is not None assert front_door_wifi_signal_strength_state.state == "-58" + + +async def test_only_chime_devices( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + freezer: FrozenDateTimeFactory, + caplog, +) -> None: + """Tests the update service works correctly if only chimes are returned.""" + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + requests_mock.get( + "https://api.ring.com/clients_api/ring_devices", + text=load_fixture("chime_devices.json", "ring"), + ) + await setup_platform(hass, Platform.SENSOR) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + caplog.clear() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "UnboundLocalError" not in caplog.text # For issue #109210 diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 468b4f0d0ec..b856a2f850c 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,10 @@ """The tests for the Ring switch platform.""" import requests_mock -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .common import setup_platform @@ -84,7 +85,13 @@ async def test_updates_work( text=load_fixture("devices_updated.json", "ring"), ) - await hass.services.async_call("ring", "update", {}, blocking=True) + await async_setup_component(hass, "homeassistant", {}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.front_siren"]}, + blocking=True, + ) 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 8207ad819b7..cc6cefc1325 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -383,14 +383,14 @@ async def test_ha_to_risco_schema(hass: HomeAssistant) -> None: ) # Test an HA state that isn't used - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result["flow_id"], user_input={**TEST_HA_TO_RISCO, "armed_custom_bypass": "D"}, ) # Test a combo that can't be selected - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result["flow_id"], user_input={**TEST_HA_TO_RISCO, "armed_night": "A"}, diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 6d851e41bce..6bcd2152a95 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -208,7 +208,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ + 'cleaningBrushTimeLeft': 1079935, 'cleaningBrushWorkTimes': 65, + 'dustCollectionTimeLeft': 80975, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -219,6 +221,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, + 'strainerTimeLeft': 539935, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ @@ -482,7 +485,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ + 'cleaningBrushTimeLeft': 1079935, 'cleaningBrushWorkTimes': 65, + 'dustCollectionTimeLeft': 80975, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -493,6 +498,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, + 'strainerTimeLeft': 539935, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 080893f1d95..ecc501cc542 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch import pytest +from roborock import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( @@ -15,12 +16,12 @@ from homeassistant.components.vacuum import ( SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START, - SERVICE_START_PAUSE, SERVICE_STOP, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -46,7 +47,6 @@ async def test_registry_entries( (SERVICE_RETURN_TO_BASE, RoborockCommand.APP_CHARGE, None, None), (SERVICE_CLEAN_SPOT, RoborockCommand.APP_SPOT, None, None), (SERVICE_LOCATE, RoborockCommand.FIND_ME, None, None), - (SERVICE_START_PAUSE, RoborockCommand.APP_START, None, None), ( SERVICE_SET_FAN_SPEED, RoborockCommand.SET_CUSTOM_MODE, @@ -90,35 +90,20 @@ async def test_commands( assert mock_send_command.call_args[0][1] == called_params -@pytest.mark.parametrize( - ("service", "issue_id"), - [ - (SERVICE_START_PAUSE, "service_deprecation_start_pause"), - ], -) -async def test_issues( +async def test_failed_user_command( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, - service: str, - issue_id: str, ) -> None: - """Test issues raised by calling deprecated services.""" - vacuum = hass.states.get(ENTITY_ID) - assert vacuum - - data = {ATTR_ENTITY_ID: ENTITY_ID} + """Test that when a user sends an invalid command, we raise HomeAssistantError.""" + data = {ATTR_ENTITY_ID: ENTITY_ID, **{"command": "fake_command"}} with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command" - ): + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command", + side_effect=RoborockException(), + ), pytest.raises(HomeAssistantError, match="Error while calling fake_command"): await hass.services.async_call( Platform.VACUUM, - service, + SERVICE_SEND_COMMAND, data, blocking=True, ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue("roborock", issue_id) - assert issue.is_fixable is True - assert issue.is_persistent is True diff --git a/tests/components/romy/__init__.py b/tests/components/romy/__init__.py new file mode 100644 index 00000000000..0e2e035f0f4 --- /dev/null +++ b/tests/components/romy/__init__.py @@ -0,0 +1 @@ +"""Tests for the ROMY integration.""" diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py new file mode 100644 index 00000000000..a24a3f46bfa --- /dev/null +++ b/tests/components/romy/test_config_flow.py @@ -0,0 +1,248 @@ +"""Test the ROMY config flow.""" +from ipaddress import ip_address +from unittest.mock import Mock, PropertyMock, patch + +from romy import RomyRobot + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf +from homeassistant.components.romy.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + + +def _create_mocked_romy( + is_initialized, + is_unlocked, + name="Agon", + user_name="MyROMY", + unique_id="aicu-aicgsbksisfapcjqmqjq", + model="005:000:000:000:005", + port=8080, +): + mocked_romy = Mock(spec_set=RomyRobot) + type(mocked_romy).is_initialized = PropertyMock(return_value=is_initialized) + type(mocked_romy).is_unlocked = PropertyMock(return_value=is_unlocked) + type(mocked_romy).name = PropertyMock(return_value=name) + type(mocked_romy).user_name = PropertyMock(return_value=user_name) + type(mocked_romy).unique_id = PropertyMock(return_value=unique_id) + type(mocked_romy).port = PropertyMock(return_value=port) + type(mocked_romy).model = PropertyMock(return_value=model) + + return mocked_romy + + +CONFIG = {CONF_HOST: "1.2.3.4", CONF_PASSWORD: "12345678"} + +INPUT_CONFIG_HOST = { + CONF_HOST: CONFIG[CONF_HOST], +} + + +async def test_show_user_form_robot_is_offline_and_locked(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + # Robot not reachable + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + assert result1["errors"].get("host") == "cannot_connect" + assert result1["step_id"] == "user" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is locked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"host": "1.2.3.4"} + ) + + assert result2["step_id"] == "password" + assert result2["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is initialized and unlocked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result3 + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_show_user_form_robot_unlock_with_password(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"password": "12345678"} + ) + + assert result2["errors"] == {"password": "invalid_auth"} + assert result2["step_id"] == "password" + assert result2["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"password": "12345678"} + ) + + assert result3["errors"] == {"password": "cannot_connect"} + assert result3["step_id"] == "password" + assert result3["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result4 + assert result4["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + # Robot not reachable + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + assert result1["errors"].get("host") == "cannot_connect" + assert result1["step_id"] == "user" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is locked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"host": "1.2.3.4"} + ) + + assert "errors" not in result2 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], + port=8080, + hostname="aicu-aicgsbksisfapcjqmqjq.local", + type="mock_type", + name="myROMY", + properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, +) + + +async def test_zero_conf_locked_interface_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered locked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result1["step_id"] == "password" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result2 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_zero_conf_uninitialized_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered locked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["reason"] == "cannot_connect" + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + +async def test_zero_conf_unlocked_interface_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered already unlocked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4"}, + ) + + assert result["data"] + assert result["data"][CONF_HOST] == "1.2.3.4" + + assert result["result"] + assert result["result"].unique_id == "aicu-aicgsbksisfapcjqmqjq" + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 403ea7d0ca7..cda3836a0a4 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -98,7 +98,6 @@ async def test_restoring_clients(hass: HomeAssistant) -> None: ) with RuckusAjaxApiPatchContext(active_clients={}): - 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/script/test_init.py b/tests/components/script/test_init.py index 83abd37137e..5f8e04d527a 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1152,7 +1152,7 @@ async def test_script_restore_last_triggered(hass: HomeAssistant) -> None: State("script.last_triggered", STATE_OFF, {"last_triggered": time}), ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, @@ -1373,7 +1373,7 @@ async def test_recursive_script_turn_on( await asyncio.wait_for(service_called.wait(), 1) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) diff --git a/tests/components/sensibo/conftest.py b/tests/components/sensibo/conftest.py index b2798224b14..17c295b4c48 100644 --- a/tests/components/sensibo/conftest.py +++ b/tests/components/sensibo/conftest.py @@ -62,10 +62,14 @@ async def get_data_from_library( return output -@pytest.fixture(name="load_json", scope="session") -def load_json_from_fixture() -> SensiboData: +@pytest.fixture(name="load_json") +def load_json_from_fixture(load_data: str) -> SensiboData: """Load fixture with json data and return.""" - - data_fixture = load_fixture("data.json", "sensibo") - json_data: dict[str, Any] = json.loads(data_fixture) + json_data: dict[str, Any] = json.loads(load_data) return json_data + + +@pytest.fixture(name="load_data", scope="session") +def load_data_from_fixture() -> str: + """Load fixture with fixture data and return.""" + return load_fixture("data.json", "sensibo") diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 0a5a9d78b1b..1e02ee63a9a 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -20,7 +20,7 @@ ]), 'max_temp': 20, 'min_temp': 10, - 'supported_features': , + 'supported_features': , 'swing_mode': 'stopped', 'swing_modes': list([ 'stopped', diff --git a/tests/components/sensibo/snapshots/test_diagnostics.ambr b/tests/components/sensibo/snapshots/test_diagnostics.ambr index b1cda16fb4d..c911a7629be 100644 --- a/tests/components/sensibo/snapshots/test_diagnostics.ambr +++ b/tests/components/sensibo/snapshots/test_diagnostics.ambr @@ -240,3 +240,1432 @@ dict({ }) # --- +# name: test_diagnostics[full_snapshot] + dict({ + 'AAZZAAZZ': dict({ + 'ac_states': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + 'mode', + 'fanLevel', + 'light', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.0, + 'co2': None, + 'device_on': False, + 'etoh': None, + 'fan_mode': 'low', + 'fan_modes': list([ + 'low', + 'high', + ]), + 'fan_modes_translated': dict({ + 'high': 'high', + 'low': 'low', + }), + 'feelslike': None, + 'filter_clean': False, + 'filter_last_reset': '2022-04-23T15:58:45+00:00', + 'full_capabilities': dict({ + 'modes': dict({ + 'fan': dict({ + 'fanLevels': list([ + 'low', + 'high', + ]), + 'light': list([ + 'on', + 'dim', + 'off', + ]), + 'temperatures': dict({ + }), + }), + }), + }), + 'fw_type': 'pure-esp32', + 'fw_ver': 'PUR00111', + 'fw_ver_available': 'PUR00111', + 'horizontal_swing_mode': None, + 'horizontal_swing_modes': None, + 'horizontal_swing_modes_translated': None, + 'humidity': None, + 'hvac_mode': 'fan', + 'hvac_modes': list([ + 'fan', + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': 'on', + 'light_modes': list([ + 'on', + 'dim', + 'off', + ]), + 'light_modes_translated': dict({ + 'dim': 'dim', + 'off': 'off', + 'on': 'on', + }), + 'location_id': 'ZZZZZZZZZZZZ', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'pure', + 'motion_sensors': dict({ + }), + 'name': 'Kitchen', + 'pm25': 1, + 'pure_ac_integration': False, + 'pure_boost_enabled': False, + 'pure_conf': dict({ + 'ac_integration': False, + 'enabled': False, + 'geo_integration': False, + 'measurements_integration': True, + 'prime_integration': False, + 'sensitivity': 'N', + }), + 'pure_geo_integration': False, + 'pure_measure_integration': True, + 'pure_prime_integration': False, + 'pure_sensitivity': 'n', + 'rcda': None, + 'room_occupied': None, + 'schedules': dict({ + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + }), + 'smart_high_temp_threshold': None, + 'smart_low_state': dict({ + }), + 'smart_low_temp_threshold': None, + 'smart_on': None, + 'smart_type': None, + 'state': 'fan', + 'swing_mode': None, + 'swing_modes': None, + 'swing_modes_translated': None, + 'target_temp': None, + 'temp': None, + 'temp_list': list([ + 0, + 1, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': None, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': False, + }), + 'ABC999111': dict({ + 'ac_states': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 25, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.019722Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + 'mode', + 'fanLevel', + 'targetTemperature', + 'swing', + 'horizontalSwing', + 'light', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.1, + 'co2': None, + 'device_on': True, + 'etoh': None, + 'fan_mode': 'high', + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'fan_modes_translated': dict({ + 'low': 'low', + 'medium': 'medium', + 'quiet': 'quiet', + }), + 'feelslike': 21.2, + 'filter_clean': True, + 'filter_last_reset': '2022-03-12T15:24:26+00:00', + 'full_capabilities': dict({ + 'modes': dict({ + 'auto': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'cool': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'dry': dict({ + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'fan': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + }), + }), + 'heat': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 63, + 64, + 66, + ]), + }), + }), + }), + }), + }), + 'fw_type': 'esp8266ex', + 'fw_ver': 'SKY30046', + 'fw_ver_available': 'SKY30048', + 'horizontal_swing_mode': 'stopped', + 'horizontal_swing_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), + 'horizontal_swing_modes_translated': dict({ + 'fixedcenterleft': 'fixedCenterLeft', + 'fixedleft': 'fixedLeft', + 'stopped': 'stopped', + }), + 'humidity': 32.9, + 'hvac_mode': 'heat', + 'hvac_modes': list([ + 'cool', + 'heat', + 'dry', + 'auto', + 'fan', + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': 'on', + 'light_modes': list([ + 'on', + 'off', + ]), + 'light_modes_translated': dict({ + 'off': 'off', + 'on': 'on', + }), + 'location_id': 'ZZZZZZZZZZZZ', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'skyv2', + 'motion_sensors': dict({ + 'AABBCC': dict({ + '__type': "", + 'repr': "MotionSensor(id='AABBCC', alive=True, motion=True, fw_ver='V17', fw_type='nrf52', is_main_sensor=True, battery_voltage=3000, humidity=57, temperature=23.9, model='motion_sensor', rssi=-72)", + }), + }), + 'name': 'Hallway', + 'pm25': None, + 'pure_ac_integration': None, + 'pure_boost_enabled': None, + 'pure_conf': dict({ + }), + 'pure_geo_integration': None, + 'pure_measure_integration': None, + 'pure_prime_integration': None, + 'pure_sensitivity': None, + 'rcda': None, + 'room_occupied': True, + 'schedules': dict({ + '11': dict({ + '__type': "", + 'repr': "Schedules(id='11', enabled=False, name=None, state_on=False, state_full={'on': False, 'targettemperature': 21, 'temperatureunit': 'c', 'mode': 'heat', 'fanlevel': 'low', 'swing': 'stopped', 'extra': {'scheduler': {'climate_react': None, 'motion': None, 'on': False, 'climate_react_settings': None, 'pure_boost': None}}, 'horizontalswing': 'stopped', 'light': 'on'}, days=['wednesday', 'thursday'], time='17:40', next_utc=datetime.datetime(2022, 5, 4, 15, 40, tzinfo=datetime.timezone.utc))", + }), + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + 'fanlevel': 'high', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }), + 'smart_high_temp_threshold': 27.5, + 'smart_low_state': dict({ + 'fanlevel': 'low', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }), + 'smart_low_temp_threshold': 0.0, + 'smart_on': False, + 'smart_type': 'temperature', + 'state': 'heat', + 'swing_mode': 'stopped', + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'swing_modes_translated': dict({ + 'fixedmiddletop': 'fixedMiddleTop', + 'fixedtop': 'fixedTop', + 'stopped': 'stopped', + }), + 'target_temp': 25, + 'temp': 21.2, + 'temp_list': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': False, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': True, + }), + 'BBZZBBZZ': dict({ + 'ac_states': dict({ + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.0, + 'co2': None, + 'device_on': False, + 'etoh': None, + 'fan_mode': None, + 'fan_modes': None, + 'fan_modes_translated': None, + 'feelslike': None, + 'filter_clean': False, + 'filter_last_reset': '2022-04-23T15:58:45+00:00', + 'full_capabilities': dict({ + }), + 'fw_type': 'pure-esp32', + 'fw_ver': 'PUR00111', + 'fw_ver_available': 'PUR00111', + 'horizontal_swing_mode': None, + 'horizontal_swing_modes': None, + 'horizontal_swing_modes_translated': None, + 'humidity': None, + 'hvac_mode': None, + 'hvac_modes': list([ + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': None, + 'light_modes': None, + 'light_modes_translated': None, + 'location_id': 'ZZZZZZZZZZYY', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'pure', + 'motion_sensors': dict({ + }), + 'name': 'Bedroom', + 'pm25': 1, + 'pure_ac_integration': False, + 'pure_boost_enabled': False, + 'pure_conf': dict({ + }), + 'pure_geo_integration': False, + 'pure_measure_integration': False, + 'pure_prime_integration': False, + 'pure_sensitivity': 'n', + 'rcda': None, + 'room_occupied': None, + 'schedules': dict({ + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + }), + 'smart_high_temp_threshold': None, + 'smart_low_state': dict({ + }), + 'smart_low_temp_threshold': None, + 'smart_on': None, + 'smart_type': None, + 'state': 'off', + 'swing_mode': None, + 'swing_modes': None, + 'swing_modes_translated': None, + 'target_temp': None, + 'temp': None, + 'temp_list': list([ + 0, + 1, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': None, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': False, + }), + 'raw': dict({ + 'result': list([ + dict({ + 'acState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 25, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.019722Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 32, + 'time': '2022-04-30T11:22:57.894846Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'SKY30048', + 'features': list([ + 'softShowPlus', + 'optimusTrial', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 667991, + 'filtersCleanSecondsThreshold': 1080000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 4219143, + 'time': '2022-03-12T15:24:26Z', + }), + 'shouldCleanFilters': True, + }), + 'firmwareType': 'esp8266ex', + 'firmwareVersion': 'SKY30046', + 'homekitSupported': False, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'swing': 'stopped', + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.062645Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'swing': 'stopped', + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.062688Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 120, + 'time': '2022-04-30T11:21:29Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 181, + 'time': '2022-04-30T11:20:28Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': dict({ + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'firmwareType': 'nrf52', + 'firmwareVersion': 'V17', + 'id': '**REDACTED**', + 'isMainSensor': True, + 'macAddress': '**REDACTED**', + 'measurements': dict({ + 'batteryVoltage': 3000, + 'humidity': 32.9, + 'motion': False, + 'rssi': -72, + 'temperature': 21.2, + 'time': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'parentDeviceUid': '**REDACTED**', + 'productModel': 'motion_sensor', + 'qrId': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'measurements': dict({ + 'feelsLike': 21.2, + 'humidity': 32.9, + 'motion': True, + 'roomIsOccupied': True, + 'rssi': -45, + 'temperature': 21.2, + 'time': dict({ + 'secondsAgo': 32, + 'time': '2022-04-30T11:22:57.894846Z', + }), + }), + 'motionConfig': dict({ + 'enabled': True, + 'onEnterACChange': False, + 'onEnterACState': None, + 'onEnterCRChange': True, + 'onExitACChange': True, + 'onExitACState': None, + 'onExitCRChange': True, + 'onExitDelayMinutes': 20, + }), + 'motionSensors': list([ + dict({ + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'firmwareType': 'nrf52', + 'firmwareVersion': 'V17', + 'id': '**REDACTED**', + 'isMainSensor': True, + 'macAddress': '**REDACTED**', + 'measurements': dict({ + 'batteryVoltage': 3000, + 'humidity': 57, + 'motion': True, + 'rssi': -72, + 'temperature': 23.9, + 'time': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'parentDeviceUid': '**REDACTED**', + 'productModel': 'motion_sensor', + 'qrId': '**REDACTED**', + 'serial': '**REDACTED**', + }), + ]), + 'productModel': 'skyv2', + 'pureBoostConfig': None, + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + '_mitsubishi2_night_heat', + ]), + 'remoteCapabilities': dict({ + 'modes': dict({ + 'auto': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'cool': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'dry': dict({ + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'fan': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + }), + }), + 'heat': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 63, + 64, + 66, + ]), + }), + }), + }), + }), + }), + 'remoteFlavor': 'Curious Sea Cucumber', + 'room': dict({ + 'icon': 'Lounge', + 'name': 'Hallway', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': True, + 'runningHealthcheck': None, + 'schedules': list([ + dict({ + 'acState': dict({ + 'extra': dict({ + 'scheduler': dict({ + 'climate_react': None, + 'climate_react_settings': None, + 'motion': None, + 'on': False, + 'pure_boost': None, + }), + }), + 'fanLevel': 'low', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': False, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'createTime': '2022-04-17T15:41:05', + 'createTimeSecondsAgo': 1107745, + 'id': '**REDACTED**', + 'isEnabled': False, + 'name': None, + 'nextTime': '2022-05-04T15:40:00', + 'nextTimeSecondsFromNow': 360989, + 'podUid': '**REDACTED**', + 'recurringDays': list([ + 'Wednesday', + 'Thursday', + ]), + 'targetTimeLocal': '17:40', + 'timezone': 'Europe/Stockholm', + }), + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.1, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': dict({ + 'deviceUid': '**REDACTED**', + 'enabled': False, + 'highTemperatureState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'highTemperatureThreshold': 27.5, + 'highTemperatureWebhook': None, + 'lowTemperatureState': dict({ + 'fanLevel': 'low', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'lowTemperatureThreshold': 0.0, + 'lowTemperatureWebhook': None, + 'type': 'temperature', + }), + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 64093221, + 'time': '2020-04-18T15:43:08Z', + }), + }), + dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'PUR00111', + 'features': list([ + 'optimusTrial', + 'softShowPlus', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 415560, + 'filtersCleanSecondsThreshold': 14256000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 588284, + 'time': '2022-04-23T15:58:45Z', + }), + 'shouldCleanFilters': False, + }), + 'firmwareType': 'pure-esp32', + 'firmwareVersion': 'PUR00111', + 'homekitSupported': True, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090144Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090185Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 108, + 'time': '2022-04-30T11:21:41Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 6003, + 'time': '2022-04-30T09:43:26Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': None, + 'measurements': dict({ + 'motion': False, + 'pm25': 1, + 'roomIsOccupied': None, + 'rssi': -58, + 'time': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'motionConfig': None, + 'motionSensors': list([ + ]), + 'productModel': 'pure', + 'pureBoostConfig': dict({ + 'ac_integration': False, + 'enabled': False, + 'geo_integration': False, + 'measurements_integration': True, + 'prime_integration': False, + 'sensitivity': 'N', + }), + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + ]), + 'remoteCapabilities': dict({ + 'modes': dict({ + 'fan': dict({ + 'fanLevels': list([ + 'low', + 'high', + ]), + 'light': list([ + 'on', + 'dim', + 'off', + ]), + 'temperatures': dict({ + }), + }), + }), + }), + 'remoteFlavor': 'Eccentric Eagle', + 'room': dict({ + 'icon': 'Diningroom', + 'name': 'Kitchen', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': None, + 'runningHealthcheck': None, + 'schedules': list([ + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.0, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': None, + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 1733071, + 'time': '2022-04-10T09:58:58Z', + }), + }), + dict({ + 'acState': dict({ + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'PUR00111', + 'features': list([ + 'optimusTrial', + 'softShowPlus', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 415560, + 'filtersCleanSecondsThreshold': 14256000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 588284, + 'time': '2022-04-23T15:58:45Z', + }), + 'shouldCleanFilters': False, + }), + 'firmwareType': 'pure-esp32', + 'firmwareVersion': 'PUR00111', + 'homekitSupported': True, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090144Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090185Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 108, + 'time': '2022-04-30T11:21:41Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 6003, + 'time': '2022-04-30T09:43:26Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': None, + 'measurements': dict({ + 'motion': False, + 'pm25': 1, + 'roomIsOccupied': None, + 'rssi': -58, + 'time': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'motionConfig': None, + 'motionSensors': list([ + ]), + 'productModel': 'pure', + 'pureBoostConfig': None, + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + ]), + 'remoteCapabilities': None, + 'remoteFlavor': 'Eccentric Eagle', + 'room': dict({ + 'icon': 'Diningroom', + 'name': 'Bedroom', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': None, + 'runningHealthcheck': None, + 'schedules': list([ + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.0, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': None, + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 1733071, + 'time': '2022-04-10T09:58:58Z', + }), + }), + ]), + 'status': 'success', + }), + }) +# --- diff --git a/tests/components/sensibo/test_diagnostics.py b/tests/components/sensibo/test_diagnostics.py index bc35b7fdd57..320125e6403 100644 --- a/tests/components/sensibo/test_diagnostics.py +++ b/tests/components/sensibo/test_diagnostics.py @@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +EXCLUDE_ATTRIBUTES = {"full_features"} + async def test_diagnostics( hass: HomeAssistant, @@ -28,3 +30,9 @@ async def test_diagnostics( assert diag["ABC999111"]["smart_low_state"] == snapshot assert diag["ABC999111"]["smart_high_state"] == snapshot assert diag["ABC999111"]["pure_conf"] == snapshot + + def limit_attrs(prop, path): + exclude_attrs = EXCLUDE_ATTRIBUTES + return prop in exclude_attrs + + assert diag == snapshot(name="full_snapshot", exclude=limit_attrs) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 522afe3b992..a120ad8db78 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +import logging from types import ModuleType from typing import Any @@ -29,13 +30,16 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfDataRate, UnitOfEnergy, UnitOfLength, UnitOfMass, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, State @@ -170,12 +174,11 @@ async def test_deprecated_last_reset( "Entity sensor.test () " f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " - "your configuration if state_class is manually configured, otherwise report it " - "to the author of the 'test' custom integration" + "your configuration if state_class is manually configured." ) in caplog.text state = hass.states.get("sensor.test") - assert "last_reset" not in state.attributes + assert state is None async def test_datetime_conversion( @@ -581,6 +584,38 @@ async def test_restore_sensor_restore_state( -0.00001, "0", ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + 50.0, + "13.2", + ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 13.0, + "49.2", + ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.SECONDS, + UnitOfTime.HOURS, + UnitOfTime.HOURS, + 5400.0, + "1.5000", + ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.DAYS, + UnitOfTime.MINUTES, + UnitOfTime.MINUTES, + 0.5, + "720.0", + ), ], ) async def test_custom_unit( @@ -2588,3 +2623,120 @@ def test_deprecated_constants_sensor_device_class( import_and_test_deprecated_constant_enum( caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" ) + + +@pytest.mark.parametrize( + ("device_class", "native_unit"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS), + (SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND), + ], +) +async def test_suggested_unit_guard_invalid_unit( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class: SensorDeviceClass, + native_unit: str, +) -> None: + """Test suggested_unit_of_measurement guard. + + An invalid suggested unit creates a log entry and the suggested unit will be ignored. + """ + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + state_value = 10 + invalid_suggested_unit = "invalid_unit" + + entity = platform.ENTITIES["0"] = platform.MockSensor( + name="Invalid", + device_class=device_class, + native_unit_of_measurement=native_unit, + suggested_unit_of_measurement=invalid_suggested_unit, + native_value=str(state_value), + unique_id="invalid", + ) + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Unit of measurement should be native one + state = hass.states.get(entity.entity_id) + assert int(state.state) == state_value + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + # Assert the suggested unit is ignored and not stored in the entity registry + entry = entity_registry.async_get(entity.entity_id) + assert entry.unit_of_measurement == native_unit + assert entry.options == {} + assert ( + "homeassistant.components.sensor", + logging.WARNING, + ( + " sets an" + " invalid suggested_unit_of_measurement. Please report it to the author" + " of the 'test' custom integration. This warning will become an error in" + " Home Assistant Core 2024.5" + ), + ) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("device_class", "native_unit", "native_value", "suggested_unit", "expect_value"), + [ + ( + SensorDeviceClass.TEMPERATURE, + UnitOfTemperature.CELSIUS, + 10, + UnitOfTemperature.KELVIN, + 283, + ), + ( + SensorDeviceClass.DATA_RATE, + UnitOfDataRate.KILOBITS_PER_SECOND, + 10, + UnitOfDataRate.BITS_PER_SECOND, + 10000, + ), + ], +) +async def test_suggested_unit_guard_valid_unit( + hass: HomeAssistant, + device_class: SensorDeviceClass, + native_unit: str, + native_value: int, + suggested_unit: str, + expect_value: float | int, +) -> None: + """Test suggested_unit_of_measurement guard. + + Suggested unit is valid and therefore should be used for unit conversion and stored + in the entity registry. + """ + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + entity = platform.ENTITIES["0"] = platform.MockSensor( + name="Valid", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="valid", + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Unit of measurement should set to the suggested unit of measurement + state = hass.states.get(entity.entity_id) + assert float(state.state) == expect_value + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + + # Assert the suggested unit of measurement is stored in the registry + entry = entity_registry.async_get(entity.entity_id) + assert entry.unit_of_measurement == suggested_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": suggested_unit}, + } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 34aaeda6740..2dcc873ca8b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2422,6 +2422,7 @@ def test_list_statistic_ids_unsupported( (None, "kW", "Wh", "power", 13.050847, -10, 30), # Can't downgrade from ft³ to ft3 or from m³ to m3 (None, "ft³", "ft3", "volume", 13.050847, -10, 30), + (None, "ft³/min", "ft³/m", "volume_flow_rate", 13.050847, -10, 30), (None, "m³", "m3", "volume", 13.050847, -10, 30), ], ) @@ -2887,6 +2888,17 @@ def test_compile_hourly_statistics_convert_units_1( (None, "RPM", "rpm", None, None, 13.050847, 13.333333, -10, 30), (None, "rpm", "RPM", None, None, 13.050847, 13.333333, -10, 30), (None, "ft3", "ft³", None, "volume", 13.050847, 13.333333, -10, 30), + ( + None, + "ft³/m", + "ft³/min", + None, + "volume_flow_rate", + 13.050847, + 13.333333, + -10, + 30, + ), (None, "m3", "m³", None, "volume", 13.050847, 13.333333, -10, 30), ], ) @@ -3010,6 +3022,7 @@ def test_compile_hourly_statistics_equivalent_units_1( (None, "RPM", "rpm", None, 13.333333, -10, 30), (None, "rpm", "RPM", None, 13.333333, -10, 30), (None, "ft3", "ft³", None, 13.333333, -10, 30), + (None, "ft³/m", "ft³/min", None, 13.333333, -10, 30), (None, "m3", "m³", None, 13.333333, -10, 30), ], ) diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index f6f6445a0fb..b67a353932a 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -52,7 +52,7 @@ def test_compile_missing_statistics( start_time = three_days_ago + timedelta(days=3) freezer.move_to(three_days_ago) hass: HomeAssistant = get_test_home_assistant() - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) setup_component(hass, "sensor", {}) setup_component(hass, "recorder", {"recorder": config}) @@ -90,7 +90,7 @@ def test_compile_missing_statistics( hass.stop() freezer.move_to(start_time) hass: HomeAssistant = get_test_home_assistant() - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) setup_component(hass, "sensor", {}) hass.states.set("sensor.test1", "0", POWER_SENSOR_ATTRIBUTES) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 26040e13557..daf96db13d3 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -74,7 +74,7 @@ def mutate_rpc_device_status( def inject_rpc_device_event( monkeypatch: pytest.MonkeyPatch, mock_rpc_device: Mock, - event: dict[str, dict[str, Any]], + event: Mapping[str, list[dict[str, Any]] | float], ) -> None: """Inject event for rpc device.""" monkeypatch.setattr(mock_rpc_device, "event", event) @@ -121,6 +121,13 @@ def register_entity( return f"{domain}.{object_id}" +def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: + """Return entity state.""" + entity = hass.states.get(entity_id) + assert entity + return entity.state + + def register_device(device_reg, config_entry: ConfigEntry): """Register Shelly device.""" device_reg.async_get_or_create( diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index 9fe5f77f00c..d9ec0064606 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from .. import init_integration, inject_rpc_device_event -async def test_scanner(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: - """Test injecting data into the scanner.""" +async def test_scanner_v1(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test injecting data into the scanner v1.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} ) @@ -49,6 +49,48 @@ async def test_scanner(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> Non assert ble_device is None +async def test_scanner_v2(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test injecting data into the scanner v2.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [ + 2, + [ + [ + "aa:bb:cc:dd:ee:ff", + -62, + "AgEGCf9ZANH7O3TIkA==", + "EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==", + ] + ], + ], + "event": BLE_SCAN_RESULT_EVENT, + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=False + ) + assert ble_device is not None + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=True + ) + assert ble_device is None + + async def test_scanner_ignores_non_ble_events( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8a863a852f5..9d7bb9404f8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -12,6 +12,7 @@ from homeassistant.components.shelly.const import ( EVENT_SHELLY_CLICK, REST_SENSORS_UPDATE_INTERVAL, ) +from homeassistant.core import HomeAssistant from . import MOCK_MAC @@ -158,6 +159,7 @@ MOCK_CONFIG = { "ui_data": {}, "device": {"name": "Test name"}, }, + "wifi": {"sta": {"enable": True}}, } MOCK_SHELLY_COAP = { @@ -252,19 +254,19 @@ def mock_ws_server(): @pytest.fixture -def device_reg(hass): +def device_reg(hass: HomeAssistant): """Return an empty, loaded, registry.""" return mock_device_registry(hass) @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture -def events(hass): +def events(hass: HomeAssistant): """Yield caught shelly_click events.""" return async_capture_events(hass, EVENT_SHELLY_CLICK) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 27aa8710621..f17d8491782 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,7 +3,11 @@ 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 aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -33,6 +37,7 @@ import homeassistant.helpers.issue_registry as ir from . import ( MOCK_MAC, + get_entity_state, init_integration, inject_rpc_device_event, mock_polling_rpc_update, @@ -185,6 +190,27 @@ async def test_block_rest_update_auth_error( assert flow["context"].get("entry_id") == entry.entry_id +async def test_block_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch +) -> None: + """Test block device polling authentication error.""" + monkeypatch.setattr( + mock_block_device, + "update", + AsyncMock(side_effect=FirmwareUnsupported), + ) + entry = await init_integration(hass, 1) + + assert entry.state is ConfigEntryState.LOADED + + # Move time to generate polling + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + async def test_block_polling_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: @@ -196,14 +222,14 @@ async def test_block_polling_connection_error( ) await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_ON # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1").state == STATE_UNAVAILABLE + assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE async def test_block_rest_update_connection_error( @@ -216,7 +242,7 @@ async def test_block_rest_update_connection_error( await init_integration(hass, 1) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_ON + assert get_entity_state(hass, entity_id) == STATE_ON monkeypatch.setattr( mock_block_device, @@ -225,7 +251,7 @@ async def test_block_rest_update_connection_error( ) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( @@ -239,14 +265,14 @@ async def test_block_sleeping_device_no_periodic_updates( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.1" + assert get_entity_state(hass, entity_id) == "22.1" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_block_device_push_updates_failure( @@ -496,14 +522,35 @@ async def test_rpc_sleeping_device_no_periodic_updates( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert get_entity_state(hass, entity_id) == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + + +async def test_rpc_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch +) -> None: + """Test RPC update entry unsupported firmware.""" + entry = await init_integration(hass, 2) + register_entity( + hass, + SENSOR_DOMAIN, + "test_name_temperature", + "temperature:0-temperature_0", + entry, + ) + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED async def test_rpc_reconnect_auth_error( @@ -581,7 +628,7 @@ async def test_rpc_reconnect_error( """Test RPC reconnect error.""" await init_integration(hass, 2) - assert hass.states.get("switch.test_switch_0").state == STATE_ON + assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr( @@ -597,7 +644,7 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0").state == STATE_UNAVAILABLE + assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE async def test_rpc_polling_connection_error( @@ -615,11 +662,11 @@ async def test_rpc_polling_connection_error( ), ) - assert hass.states.get(entity_id).state == "-63" + assert get_entity_state(hass, entity_id) == "-63" await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( @@ -631,11 +678,11 @@ async def test_rpc_polling_disconnected( monkeypatch.setattr(mock_rpc_device, "connected", False) - assert hass.states.get(entity_id).state == "-63" + assert get_entity_state(hass, entity_id) == "-63" await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_rpc_update_entry_fw_ver( @@ -649,11 +696,12 @@ async def test_rpc_update_entry_fw_ver( mock_rpc_device.mock_update() await hass.async_block_till_done() + assert entry.unique_id device = dev_reg.async_get_device( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) - + assert device assert device.sw_version == "some fw string" monkeypatch.setattr(mock_rpc_device, "firmware_version", "99.0.0") @@ -665,5 +713,5 @@ async def test_rpc_update_entry_fw_ver( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) - + assert device assert device.sw_version == "99.0.0" diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index bc0ba045a55..0cd206e33a2 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -79,15 +80,21 @@ async def test_setup_entry_not_shelly( @pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize("side_effect", [DeviceConnectionError, FirmwareUnsupported]) async def test_device_connection_error( - hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch + hass: HomeAssistant, + gen, + side_effect, + mock_block_device, + mock_rpc_device, + monkeypatch, ) -> None: """Test device connection error.""" monkeypatch.setattr( - mock_block_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_block_device, "initialize", AsyncMock(side_effect=side_effect) ) monkeypatch.setattr( - mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_rpc_device, "initialize", AsyncMock(side_effect=side_effect) ) entry = await init_integration(hass, gen) diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index e74d69f04c9..475a8f09e03 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -213,6 +213,8 @@ async def test_legacy_thermostat_entity_state( == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -240,6 +242,8 @@ async def test_basic_thermostat_entity_state( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert ATTR_HVAC_ACTION not in state.attributes assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -261,6 +265,8 @@ async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -288,6 +294,8 @@ async def test_buggy_thermostat_entity_state( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state is STATE_UNKNOWN assert state.attributes[ATTR_TEMPERATURE] is None @@ -320,6 +328,8 @@ async def test_air_conditioner_entity_state( | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ HVACMode.COOL, diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index ccf4b50fa1b..751646580d9 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -7,6 +7,8 @@ from pysmartthings import Attribute, Capability from homeassistant.components.fan import ( ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, FanEntityFeature, ) @@ -77,7 +79,87 @@ async def test_entity_and_device_attributes( assert entry.sw_version == "v7.89" -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: +# Setup platform tests with varying capabilities +async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with only the mode capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] == FanEntityFeature.PRESET_MODE + assert state.attributes[ATTR_PRESET_MODE] == "high" + assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] + + +async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with only the speed capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={ + Attribute.switch: "off", + Attribute.fan_speed: 2, + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] == FanEntityFeature.SET_SPEED + assert state.attributes[ATTR_PERCENTAGE] == 66 + + +async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with both the mode and speed capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[ + Capability.switch, + Capability.fan_speed, + Capability.air_conditioner_fan_mode, + ], + status={ + Attribute.switch: "off", + Attribute.fan_speed: 2, + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + ) + assert state.attributes[ATTR_PERCENTAGE] == 66 + assert state.attributes[ATTR_PRESET_MODE] == "high" + assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] + + +# Speed Capability Tests + + +async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: """Test the fan turns of successfully.""" # Arrange device = device_factory( @@ -96,7 +178,7 @@ async def test_turn_off(hass: HomeAssistant, device_factory) -> None: assert state.state == "off" -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: +async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: """Test the fan turns of successfully.""" # Arrange device = device_factory( @@ -115,7 +197,9 @@ async def test_turn_on(hass: HomeAssistant, device_factory) -> None: assert state.state == "on" -async def test_turn_on_with_speed(hass: HomeAssistant, device_factory) -> None: +async def test_turn_on_with_speed_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test the fan turns on to the specified speed.""" # Arrange device = device_factory( @@ -138,7 +222,33 @@ async def test_turn_on_with_speed(hass: HomeAssistant, device_factory) -> None: assert state.attributes[ATTR_PERCENTAGE] == 100 -async def test_set_percentage(hass: HomeAssistant, device_factory) -> None: +async def test_turn_off_with_speed_speed_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test the fan turns off with the speed.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: "on", Attribute.fan_speed: 100}, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", + "set_percentage", + {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + blocking=True, + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "off" + + +async def test_set_percentage_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test setting to specific fan speed.""" # Arrange device = device_factory( @@ -161,7 +271,9 @@ async def test_set_percentage(hass: HomeAssistant, device_factory) -> None: assert state.attributes[ATTR_PERCENTAGE] == 100 -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: +async def test_update_from_signal_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test the fan updates when receiving a signal.""" # Arrange device = device_factory( @@ -194,3 +306,108 @@ async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: await hass.config_entries.async_forward_entry_unload(config_entry, "fan") # Assert assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE + + +# Preset Mode Tests + + +async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "on", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "off" + assert state.attributes[ATTR_PRESET_MODE] == "high" + + +async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "on" + assert state.attributes[ATTR_PRESET_MODE] == "high" + + +async def test_update_from_signal_mode_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test the fan updates when receiving a signal.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "on" + + +async def test_set_preset_mode_mode_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test setting to specific fan mode.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", + "set_preset_mode", + {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + blocking=True, + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_PRESET_MODE] == "low" diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index fa37b2210e7..ddf550dc376 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,26 +1,45 @@ """Tests for the Sonos Media Player platform.""" from homeassistant.const import STATE_IDLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + CONNECTION_UPNP, + DeviceRegistry, +) async def test_device_registry( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, device_registry: DeviceRegistry, async_autosetup_sonos, soco ) -> None: """Test sonos device registered in the device registry.""" - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={("sonos", "RINCON_test")} ) + assert reg_device is not None assert reg_device.model == "Model Name" assert reg_device.sw_version == "13.1" assert reg_device.connections == { - (dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), - (dr.CONNECTION_UPNP, "uuid:RINCON_test"), + (CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), + (CONNECTION_UPNP, "uuid:RINCON_test"), } assert reg_device.manufacturer == "Sonos" - assert reg_device.suggested_area == "Zone A" assert reg_device.name == "Zone A" + # Default device provides battery info, area should not be suggested + assert reg_device.suggested_area is None + + +async def test_device_registry_not_portable( + hass: HomeAssistant, device_registry: DeviceRegistry, async_setup_sonos, soco +) -> None: + """Test non-portable sonos device registered in the device registry to ensure area suggested.""" + soco.get_battery_info.return_value = {} + await async_setup_sonos() + + reg_device = device_registry.async_get_device( + identifiers={("sonos", "RINCON_test")} + ) + assert reg_device is not None + assert reg_device.suggested_area == "Zone A" async def test_entity_basic( diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 9cdd026bd3b..1d3ce0878c3 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + UnitOfInformation, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger_template_entity import ( @@ -170,10 +171,10 @@ YAML_CONFIG = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_UNIT_OF_MEASUREMENT: UnitOfInformation.MEBIBYTES, CONF_UNIQUE_ID: "unique_id_12345", CONF_VALUE_TEMPLATE: "{{ value }}", - CONF_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, } } diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 9ac22f48312..6c2686cb6fe 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfInformation, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -401,9 +402,9 @@ async def test_attributes_from_yaml_setup( state = hass.states.get("sensor.get_value") assert state.state == "5" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE + assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - assert state.attributes["unit_of_measurement"] == "MiB" + assert state.attributes["unit_of_measurement"] == UnitOfInformation.MEBIBYTES async def test_binary_data_from_yaml_setup( diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py index 16b2e5f0974..a467c9553de 100644 --- a/tests/components/streamlabswater/__init__.py +++ b/tests/components/streamlabswater/__init__.py @@ -1 +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 index f871332e5f6..64fbed63520 100644 --- a/tests/components/streamlabswater/conftest.py +++ b/tests/components/streamlabswater/conftest.py @@ -3,6 +3,12 @@ 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 @@ -12,3 +18,32 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "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 00000000000..bdf4deb1d1b --- /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 00000000000..1e902371f73 --- /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 00000000000..2ca9b802bf5 --- /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 00000000000..9d8ca3a99e6 --- /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 00000000000..4f533d91b55 --- /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_sensor.py b/tests/components/streamlabswater/test_sensor.py new file mode 100644 index 00000000000..a78d4129abb --- /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/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 23a5830062e..9bf84889368 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -1,6 +1,4 @@ """Tests for Sure Petcare integration.""" -from homeassistant.components.surepetcare.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME HOUSEHOLD_ID = 987654321 HUB_ID = 123456789 @@ -82,13 +80,3 @@ MOCK_API_DATA = { "devices": [MOCK_HUB, MOCK_CAT_FLAP, MOCK_PET_FLAP, MOCK_FEEDER, MOCK_FELAQUA], "pets": [MOCK_PET], } - -MOCK_CONFIG = { - DOMAIN: { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - "feeders": [12345], - "flaps": [13579, 13576], - "pets": [24680], - }, -} diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index dd1cd19aa0e..79c1b88d99b 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -4,8 +4,14 @@ from unittest.mock import patch import pytest from surepy import MESTART_RESOURCE +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant + from . import MOCK_API_DATA +from tests.common import MockConfigEntry + async def _mock_call(method, resource): if method == "GET" and resource == MESTART_RESOURCE: @@ -21,3 +27,20 @@ async def surepetcare(): client.call = _mock_call client.get_token.return_value = "token" yield client + + +@pytest.fixture +async def mock_config_entry_setup(hass: HomeAssistant) -> MockConfigEntry: + """Help setting up a mocked config entry.""" + data = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "token", + "feeders": [12345], + "flaps": [13579, 13576], + "pets": [24680], + } + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + return entry diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py index 91677751e96..9f4018b4b65 100644 --- a/tests/components/surepetcare/test_binary_sensor.py +++ b/tests/components/surepetcare/test_binary_sensor.py @@ -1,10 +1,10 @@ """The tests for the Sure Petcare binary sensor platform.""" -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, HUB_ID, MOCK_CONFIG +from . import HOUSEHOLD_ID, HUB_ID + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "binary_sensor.pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", @@ -15,11 +15,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_binary_sensors(hass: HomeAssistant, surepetcare) -> None: +async def test_binary_sensors( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py index 19e27bbe9a5..14a6a361793 100644 --- a/tests/components/surepetcare/test_lock.py +++ b/tests/components/surepetcare/test_lock.py @@ -2,12 +2,12 @@ import pytest from surepy.exceptions import SurePetcareError -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_CONFIG, MOCK_PET_FLAP +from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_PET_FLAP + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "lock.cat_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_in", @@ -19,11 +19,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_locks(hass: HomeAssistant, surepetcare) -> None: +async def test_locks( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() @@ -78,11 +77,10 @@ async def test_locks(hass: HomeAssistant, surepetcare) -> None: assert surepetcare.unlock.call_count == 1 -async def test_lock_failing(hass: HomeAssistant, surepetcare) -> None: +async def test_lock_failing( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test handling of lock failing.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - surepetcare.lock_in.side_effect = SurePetcareError surepetcare.lock_out.side_effect = SurePetcareError surepetcare.lock.side_effect = SurePetcareError @@ -96,11 +94,10 @@ async def test_lock_failing(hass: HomeAssistant, surepetcare) -> None: assert state.state == "unlocked" -async def test_unlock_failing(hass: HomeAssistant, surepetcare) -> None: +async def test_unlock_failing( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test handling of unlock failing.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_id = list(EXPECTED_ENTITY_IDS)[0] await hass.services.async_call( diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index 219d23c0425..c0491908ca0 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -1,10 +1,10 @@ """Test the surepetcare sensor platform.""" -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, MOCK_CONFIG, MOCK_FELAQUA +from . import HOUSEHOLD_ID, MOCK_FELAQUA + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "sensor.pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", @@ -14,11 +14,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_sensors(hass: HomeAssistant, surepetcare) -> None: +async def test_sensors( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 55ad51c45c4..5870f6f0555 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -65,7 +65,7 @@ async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: (IndexError(), "unknown"), ], ) -async def test_flow_user_init_data_unknown_error_and_recover( +async def test_flow_user_init_data_error_and_recover( hass: HomeAssistant, raise_error, text_error ) -> None: """Test unknown errors.""" @@ -88,9 +88,6 @@ async def test_flow_user_init_data_unknown_error_and_recover( # 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, @@ -108,20 +105,26 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No entry = MockConfigEntry( domain=config_flow.DOMAIN, data=MOCK_DATA_STEP, + unique_id=f"{MOCK_DATA_STEP[CONF_START]} {MOCK_DATA_STEP[CONF_DESTINATION]}", ) entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + 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, - ) + 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" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" MOCK_DATA_IMPORT = { @@ -161,9 +164,7 @@ async def test_import( (IndexError(), "unknown"), ], ) -async def test_import_cannot_connect_error( - hass: HomeAssistant, raise_error, text_error -) -> None: +async def test_import_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", @@ -187,6 +188,7 @@ async def test_import_already_configured(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=config_flow.DOMAIN, data=MOCK_DATA_IMPORT, + unique_id=f"{MOCK_DATA_IMPORT[CONF_START]} {MOCK_DATA_IMPORT[CONF_DESTINATION]}", ) entry.add_to_hass(hass) diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py index f2b4e41ed71..2c8e12e04bf 100644 --- a/tests/components/swiss_public_transport/test_init.py +++ b/tests/components/swiss_public_transport/test_init.py @@ -45,25 +45,26 @@ CONNECTIONS = [ ] -async def test_migration_1_to_2( +async def test_migration_1_1_to_1_2( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test successful setup.""" + config_entry_faulty = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + title="MIGRATION_TEST", + version=1, + minor_version=1, + ) + config_entry_faulty.add_to_hass(hass) + with patch( "homeassistant.components.swiss_public_transport.OpendataTransport", return_value=AsyncMock(), ) as mock: mock().connections = CONNECTIONS - config_entry_faulty = MockConfigEntry( - domain=DOMAIN, - data=MOCK_DATA_STEP, - title="MIGRATION_TEST", - minor_version=1, - ) - config_entry_faulty.add_to_hass(hass) - # Setup the config entry await hass.config_entries.async_setup(config_entry_faulty.entry_id) await hass.async_block_till_done() diff --git a/tests/components/switch_as_x/__init__.py b/tests/components/switch_as_x/__init__.py index d7cf944e624..de6f1bac790 100644 --- a/tests/components/switch_as_x/__init__.py +++ b/tests/components/switch_as_x/__init__.py @@ -1 +1,39 @@ """The tests for Switch as X platforms.""" + +from homeassistant.const import ( + STATE_CLOSED, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_OPEN, + STATE_UNLOCKED, + Platform, +) + +PLATFORMS_TO_TEST = ( + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SIREN, + Platform.VALVE, +) + +STATE_MAP = { + False: { + Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, + Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LOCK: {STATE_ON: STATE_UNLOCKED, STATE_OFF: STATE_LOCKED}, + Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.VALVE: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, + }, + True: { + Platform.COVER: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, + Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LOCK: {STATE_ON: STATE_LOCKED, STATE_OFF: STATE_UNLOCKED}, + Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, + }, +} diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 51efbf99892..09661b0619c 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -5,23 +5,21 @@ from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN -from homeassistant.const import CONF_ENTITY_ID, Platform +from homeassistant import config_entries +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import PLATFORMS_TO_TEST, STATE_MAP -PLATFORMS_TO_TEST = ( - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SIREN, - Platform.VALVE, -) +from tests.common import MockConfigEntry @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) @@ -41,6 +39,7 @@ async def test_config_flow( result["flow_id"], { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, ) @@ -51,6 +50,7 @@ async def test_config_flow( assert result["data"] == {} assert result["options"] == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,6 +59,7 @@ async def test_config_flow( assert config_entry.data == {} assert config_entry.options == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } @@ -96,6 +97,7 @@ async def test_config_flow_registered_entity( result["flow_id"], { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, ) @@ -106,6 +108,7 @@ async def test_config_flow_registered_entity( assert result["data"] == {} assert result["options"] == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } assert len(mock_setup_entry.mock_calls) == 1 @@ -114,6 +117,7 @@ async def test_config_flow_registered_entity( assert config_entry.data == {} assert config_entry.options == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } @@ -125,26 +129,66 @@ async def test_config_flow_registered_entity( async def test_options( hass: HomeAssistant, target_domain: Platform, - mock_setup_entry: AsyncMock, ) -> None: """Test reconfiguring.""" + switch_state = STATE_ON + hass.states.async_set("switch.ceiling", switch_state) switch_as_x_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: True, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() + state = hass.states.get(f"{target_domain}.abc") + assert state.state == STATE_MAP[True][target_domain][switch_state] + config_entry = hass.config_entries.async_entries(DOMAIN)[0] assert config_entry - # Switch light has no options flow - with pytest.raises(data_entry_flow.UnknownHandler): - await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + schema_key = next(k for k in schema if k == CONF_INVERT) + assert schema_key.description["suggested_value"] is True + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_INVERT: False, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.data == {} + assert config_entry.options == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.title == "ABC" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + # Check the state of the entity has changed as expected + state = hass.states.get(f"{target_domain}.abc") + assert state.state == STATE_MAP[False][target_domain][switch_state] diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index d0aef0b9490..78a76c20beb 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -1,7 +1,13 @@ """Tests for the Switch as X Cover platform.""" + from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -28,9 +34,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.COVER, }, title="Garage Door", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -51,9 +60,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.COVER, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -120,3 +132,86 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to cover.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.COVER, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("cover.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("cover.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("cover.decorative_lights").state == STATE_CLOSED + + 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("cover.decorative_lights").state == STATE_OPEN + + 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("cover.decorative_lights").state == STATE_CLOSED diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index cf6789d439c..c459831b3ad 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Fan platform.""" from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -24,9 +29,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.FAN, }, title="Wind Machine", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,9 +55,95 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.FAN, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("fan.decorative_lights").state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF + + 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("fan.decorative_lights").state == STATE_ON + + 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("fan.decorative_lights").state == STATE_OFF + + 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("fan.decorative_lights").state == STATE_ON + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as fan entity.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.FAN, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 738127faf43..2b0a67f3984 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -6,7 +6,13 @@ from unittest.mock import patch import pytest from homeassistant.components.homeassistant import exposed_entities -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_ENTITY_ID, STATE_CLOSED, @@ -22,6 +28,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from . import PLATFORMS_TO_TEST + from tests.common import MockConfigEntry EXPOSE_SETTINGS = { @@ -30,15 +38,6 @@ EXPOSE_SETTINGS = { "conversation": True, } -PLATFORMS_TO_TEST = ( - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SIREN, - Platform.VALVE, -) - @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( @@ -52,9 +51,12 @@ async def test_config_entry_unregistered_uuid( domain=DOMAIN, options={ CONF_ENTITY_ID: fake_uuid, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -92,9 +94,12 @@ async def test_entity_registry_events( domain=DOMAIN, options={ CONF_ENTITY_ID: registry_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -169,9 +174,12 @@ async def test_device_registry_config_entry_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -224,9 +232,12 @@ async def test_device_registry_config_entry_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -258,9 +269,12 @@ async def test_config_entry_entity_id( domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.abc", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -296,9 +310,12 @@ async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) - domain=DOMAIN, options={ CONF_ENTITY_ID: registry_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -331,9 +348,12 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -360,9 +380,12 @@ async def test_setup_and_remove_config_entry( domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) @@ -409,9 +432,12 @@ async def test_reset_hidden_by( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -445,9 +471,12 @@ async def test_entity_category_inheritance( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -481,9 +510,12 @@ async def test_entity_options( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -494,7 +526,7 @@ async def test_entity_options( assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False}, } @@ -534,9 +566,12 @@ async def test_entity_name( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -550,7 +585,7 @@ async def test_entity_name( assert entity_entry.name is None assert entity_entry.original_name is None assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -592,9 +627,12 @@ async def test_custom_name_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -610,7 +648,7 @@ async def test_custom_name_1( assert entity_entry.name == "Custom entity name" assert entity_entry.original_name == "Original entity name" assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -656,9 +694,12 @@ async def test_custom_name_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -689,7 +730,7 @@ async def test_custom_name_2( assert entity_entry.name == "Old custom entity name" assert entity_entry.original_name == "Original entity name" assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -719,9 +760,12 @@ async def test_import_expose_settings_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -777,9 +821,12 @@ async def test_import_expose_settings_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -842,9 +889,12 @@ async def test_restore_expose_settings( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -871,3 +921,80 @@ async def test_restore_expose_settings( ) for assistant in EXPOSE_SETTINGS: assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_migrate( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test migration.""" + registry = er.async_get(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check migration was successful and added invert option + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.options == { + CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.version == SwitchAsXConfigFlowHandler.VERSION + assert config_entry.minor_version == SwitchAsXConfigFlowHandler.MINOR_VERSION + + # Check the state and entity registry entry are present + assert hass.states.get(f"{target_domain}.abc") is not None + assert registry.async_get(f"{target_domain}.abc") is not None + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_migrate_from_future( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test migration.""" + registry = er.async_get(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check migration was not successful and did not add invert option + assert config_entry.state == ConfigEntryState.MIGRATION_ERROR + assert config_entry.options == { + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.version == 2 + assert config_entry.minor_version == 1 + + # Check the state and entity registry entry are not present + assert hass.states.get(f"{target_domain}.abc") is None + assert registry.async_get(f"{target_domain}.abc") is None diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index 9a33bab20a8..5bdec990fd4 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -11,7 +11,12 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -34,9 +39,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="Christmas Tree Lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -64,9 +72,12 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="decorative_lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -118,9 +129,112 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("light.decorative_lights").state == STATE_ON + + 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("light.decorative_lights").state == STATE_OFF + + 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("light.decorative_lights").state == STATE_ON + + +async def test_light_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to light.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LIGHT, + }, + title="decorative_lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("light.decorative_lights").state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("light.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("light.decorative_lights").state == STATE_ON + assert ( + hass.states.get("light.decorative_lights").attributes.get(ATTR_COLOR_MODE) + == ColorMode.ONOFF + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("light.decorative_lights").state == STATE_OFF + + +async def test_switch_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to switch.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LIGHT, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index 6d30ac4646b..bdf1b754c5a 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Lock platform.""" from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_LOCK, @@ -28,9 +33,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LOCK, }, title="candy_jar", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -50,9 +58,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LOCK, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -109,3 +120,76 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as lock entity.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LOCK, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("lock.decorative_lights").state == STATE_LOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {CONF_ENTITY_ID: "lock.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {CONF_ENTITY_ID: "lock.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + + 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("lock.decorative_lights").state == STATE_UNLOCKED + + 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("lock.decorative_lights").state == STATE_LOCKED + + 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_OFF + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index f776ab2ae01..581aa74daff 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Siren platform.""" from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -24,9 +29,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.SIREN, }, title="Noise Maker", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,9 +55,95 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.SIREN, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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("siren.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF + + 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("siren.decorative_lights").state == STATE_ON + + 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("siren.decorative_lights").state == STATE_OFF + + 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("siren.decorative_lights").state == STATE_ON + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as siren entity.""" + 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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.SIREN, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py index da20c544f64..b76da012bde 100644 --- a/tests/components/switch_as_x/test_valve.py +++ b/tests/components/switch_as_x/test_valve.py @@ -1,6 +1,11 @@ """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.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.const import ( CONF_ENTITY_ID, @@ -28,9 +33,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.VALVE, }, title="Garage Door", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -51,9 +59,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.VALVE, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -120,3 +131,86 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + +async def test_service_calls_inverted(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_INVERT: True, + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + 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_CLOSED + + 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_OPEN + + 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_OFF + 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_ON + 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_CLOSED + + 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_OPEN + + 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_CLOSED diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 91556f459ba..8f66044a66b 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -52,6 +52,7 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index ca21c971cf1..c03c3fff2ca 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -1,11 +1,61 @@ """Fixtures for the System Monitor integration.""" from __future__ import annotations +from collections import namedtuple from collections.abc import Generator -from unittest.mock import AsyncMock, patch +import socket +from unittest.mock import AsyncMock, Mock, patch +from psutil import NoSuchProcess, Process +from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr, sswap import pytest +from homeassistant.components.systemmonitor.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +# Different depending on platform so making according to Linux +svmem = namedtuple( + "svmem", + [ + "total", + "available", + "percent", + "used", + "free", + "active", + "inactive", + "buffers", + "cached", + "shared", + "slab", + ], +) + + +@pytest.fixture(autouse=True) +def mock_sys_platform() -> Generator[None, None, None]: + """Mock sys platform to Linux.""" + with patch("sys.platform", "linux"): + yield + + +class MockProcess(Process): + """Mock a Process class.""" + + def __init__(self, name: str, ex: bool = False) -> None: + """Initialize the process.""" + super().__init__(1) + self._name = name + self._ex = ex + + def name(self): + """Return a name.""" + if self._ex: + raise NoSuchProcess(1, self._name) + return self._name + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -15,3 +65,186 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "sensor": {"process": ["python3", "pip"]}, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + }, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: 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 + + +@pytest.fixture +def mock_process() -> list[MockProcess]: + """Mock process.""" + _process_python = MockProcess("python3") + _process_pip = MockProcess("pip") + return [_process_python, _process_pip] + + +@pytest.fixture +def mock_psutil(mock_process: list[MockProcess]) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.coordinator.psutil", + autospec=True, + ) as mock_psutil: + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**3, 300 * 1024**3, 200 * 1024**3, 60.0 + ) + mock_psutil.swap_memory.return_value = sswap( + 100 * 1024**2, 60 * 1024**2, 40 * 1024**2, 60.0, 1, 1 + ) + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 40 * 1024**2, + 40.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + "eth1": snetio(200 * 1024**2, 200 * 1024**2, 150, 150, 0, 0, 0, 0), + "vethxyzxyz": snetio(300 * 1024**2, 300 * 1024**2, 150, 150, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_psutil.cpu_percent.return_value = 10.0 + mock_psutil.boot_time.return_value = 1703973338.0 + mock_psutil.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_psutil.sensors_temperatures = Mock() + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_psutil.NoSuchProcess = NoSuchProcess + yield mock_psutil + + +@pytest.fixture +def mock_util(mock_process) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.util.psutil", autospec=True + ) as mock_util: + mock_util.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_process = [MockProcess("python3")] + mock_util.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_util.sensors_temperatures = Mock() + mock_util.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), + sdiskpart("test3", "/incorrect", "", "", 1, 1), + sdiskpart("proc", "/proc/run", "proc", "", 1, 1), + ] + mock_util.disk_usage.return_value = sdiskusage(10, 10, 0, 0) + yield mock_util + + +@pytest.fixture +def mock_os() -> Mock: + """Mock os.""" + with patch( + "homeassistant.components.systemmonitor.coordinator.os" + ) as mock_os, patch( + "homeassistant.components.systemmonitor.util.os" + ) as mock_os_util: + mock_os_util.name = "nt" + mock_os.getloadavg.return_value = (1, 2, 3) + yield mock_os diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3708ca1e53a --- /dev/null +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -0,0 +1,399 @@ +# serializer version: 1 +# name: test_sensor[System Monitor Disk free / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free / - state] + '200.0' +# --- +# name: test_sensor[System Monitor Disk free /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free /media/share - state] + '200.0' +# --- +# name: test_sensor[System Monitor Disk usage / - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk usage /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk usage / - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk usage /home/notexist/ - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk usage /home/notexist/', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk usage /home/notexist/ - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk usage /media/share - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk usage /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk usage /media/share - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use / - state] + '300.0' +# --- +# name: test_sensor[System Monitor Disk use /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use /media/share - state] + '300.0' +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - state] + '192.168.1.1' +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - state] + '192.168.10.1' +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Last boot - attributes] + ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'System Monitor Last boot', + }) +# --- +# name: test_sensor[System Monitor Last boot - state] + '2023-12-30T21:55:38+00:00' +# --- +# name: test_sensor[System Monitor Load (15m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (15m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (15m) - state] + '3' +# --- +# name: test_sensor[System Monitor Load (1m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (1m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (1m) - state] + '1' +# --- +# name: test_sensor[System Monitor Load (5m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (5m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (5m) - state] + '2' +# --- +# name: test_sensor[System Monitor Memory free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory usage - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Memory usage', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Memory usage - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory use', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory use - state] + '60.0' +# --- +# name: test_sensor[System Monitor Network in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Packets in eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets in eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Packets out eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets out eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Process pip - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process pip', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process pip - state] + 'on' +# --- +# name: test_sensor[System Monitor Process python3 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process python3', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process python3 - state] + 'on' +# --- +# name: test_sensor[System Monitor Processor temperature - attributes] + ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'System Monitor Processor temperature', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Processor temperature - state] + '50.0' +# --- +# name: test_sensor[System Monitor Processor use - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Processor use', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Processor use - state] + '10' +# --- +# name: test_sensor[System Monitor Swap free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Swap usage - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Swap usage', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Swap usage - state] + '60.0' +# --- +# name: test_sensor[System Monitor Swap use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap use', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap use - state] + '60.0' +# --- diff --git a/tests/components/systemmonitor/test_init.py b/tests/components/systemmonitor/test_init.py new file mode 100644 index 00000000000..a352f9a1b95 --- /dev/null +++ b/tests/components/systemmonitor/test_init.py @@ -0,0 +1,60 @@ +"""Test for System Monitor init.""" +from __future__ import annotations + +from homeassistant.components.systemmonitor.const import CONF_PROCESS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_load_unload_entry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test load and unload an entry.""" + + assert mock_added_config_entry.state == ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_added_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_adding_processor_to_options( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test options listener.""" + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is None + + result = await hass.config_entries.options.async_init( + mock_added_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: ["python3", "pip", "systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["python3", "pip", "systemd"], + }, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + } + + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is not None + assert process_sensor.state == STATE_OFF diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py new file mode 100644 index 00000000000..8beeddbefdc --- /dev/null +++ b/tests/components/systemmonitor/test_sensor.py @@ -0,0 +1,431 @@ +"""Test System Monitor sensor.""" +from datetime import timedelta +import socket +from unittest.mock import Mock, patch + +from freezegun.api import FrozenDateTimeFactory +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.systemmonitor.sensor import get_cpu_icon +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import MockProcess, svmem + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + assert memory_sensor.attributes == { + "state_class": "measurement", + "unit_of_measurement": "MiB", + "device_class": "data_size", + "icon": "mdi:memory", + "friendly_name": "System Monitor Memory free", + } + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + for entity in er.async_entries_for_config_entry( + entity_registry, mock_added_config_entry.entry_id + ): + state = hass.states.get(entity.entity_id) + assert state.state == snapshot(name=f"{state.name} - state") + assert state.attributes == snapshot(name=f"{state.name} - attributes") + + +async def test_sensor_not_loading_veth_networks( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, +) -> None: + """Test the sensor.""" + network_sensor_1 = hass.states.get("sensor.system_monitor_network_out_eth1") + network_sensor_2 = hass.states.get( + "sensor.sensor.system_monitor_network_out_vethxyzxyz" + ) + assert network_sensor_1 is not None + assert network_sensor_1.state == "200.0" + assert network_sensor_2 is None + + +async def test_sensor_icon( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor icon for 32bit/64bit system.""" + + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**32): + assert get_cpu_icon() == "mdi:cpu-32-bit" + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**64): + assert get_cpu_icon() == "mdi:cpu-64-bit" + + +async def test_sensor_yaml( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "disk_use_percent"}, + {"type": "disk_use_percent", "arg": "/media/share"}, + {"type": "memory_free", "arg": "/"}, + {"type": "network_out", "arg": "eth0"}, + {"type": "process", "arg": "python3"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + +async def test_sensor_yaml_fails_missing_argument( + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML fails on missing mandatory argument.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "network_in"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + assert "Mandatory 'arg' is missing for sensor type 'network_in'" in caplog.text + + +async def test_sensor_updating( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + mock_psutil.virtual_memory.side_effect = Exception("Failed to update") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == STATE_UNAVAILABLE + + mock_psutil.virtual_memory.side_effect = None + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 25 * 1024**2, + 25.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "25.0" + + +async def test_sensor_process_fails( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test process not exist failure.""" + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + _process = MockProcess("python3", True) + + mock_psutil.process_iter.return_value = [_process] + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_OFF + + assert "Failed to load process with ID: 1, old name: python3" in caplog.text + + +async def test_sensor_network_sensors( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, +) -> None: + """Test process not exist failure.""" + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "200.0" + assert packets_out_sensor.state == "150" + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(200 * 1024**2, 200 * 1024**2, 100, 100, 0, 0, 0, 0), + "eth1": snetio(400 * 1024**2, 400 * 1024**2, 300, 300, 0, 0, 0, 0), + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "400.0" + assert packets_out_sensor.state == "300" + assert float(throughput_network_out_sensor.state) == pytest.approx(3.493, rel=0.1) + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == STATE_UNKNOWN + assert packets_out_sensor.state == STATE_UNKNOWN + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + +async def test_missing_cpu_temperature( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor when temperature missing.""" + mock_psutil.sensors_temperatures.return_value = { + "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] + } + mock_util.sensors_temperatures.return_value = { + "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # assert "Cannot read CPU / processor temperature information" in caplog.text + temp_sensor = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_sensor is None + + +async def test_processor_temperature( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the disk failures.""" + + with patch("sys.platform", "linux"): + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_psutil.sensors_temperatures.side_effect = None + 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() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == "50.0" + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch("sys.platform", "nt"): + mock_psutil.sensors_temperatures.return_value = None + mock_psutil.sensors_temperatures.side_effect = AttributeError( + "sensors_temperatures not exist" + ) + 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() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == STATE_UNAVAILABLE + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch("sys.platform", "darwin"): + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_psutil.sensors_temperatures.side_effect = None + 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() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == "50.0" + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_exception_handling_disk_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_added_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor.""" + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == "200.0" # GiB + + mock_psutil.disk_usage.return_value = None + mock_psutil.disk_usage.side_effect = OSError("Could not update /") + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + "Error fetching System Monitor Disk / coordinator data: OS error for /" + in caplog.text + ) + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == STATE_UNAVAILABLE + + mock_psutil.disk_usage.return_value = None + mock_psutil.disk_usage.side_effect = PermissionError("No access to /") + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + "Error fetching System Monitor Disk / coordinator data: OS error for /" + in caplog.text + ) + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == STATE_UNAVAILABLE + + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**3, 350 * 1024**3, 150 * 1024**3, 70.0 + ) + mock_psutil.disk_usage.side_effect = None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == "150.0" + assert disk_sensor.attributes["unit_of_measurement"] == "GiB" + + disk_sensor = hass.states.get("sensor.system_monitor_disk_usage") + assert disk_sensor is not None + assert disk_sensor.state == "70.0" + assert disk_sensor.attributes["unit_of_measurement"] == "%" diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py new file mode 100644 index 00000000000..c0c6829a752 --- /dev/null +++ b/tests/components/systemmonitor/test_util.py @@ -0,0 +1,90 @@ +"""Test System Monitor utils.""" + +from unittest.mock import Mock, patch + +from psutil._common import sdiskpart +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (PermissionError("No permission"), "No permission for running user to access"), + (OSError("OS error"), "was excluded because of: OS error"), + ], +) +async def test_disk_setup_failure( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the disk failures.""" + + with patch( + "homeassistant.components.systemmonitor.util.psutil.disk_usage", + 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() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free_media_share") + assert disk_sensor is None + + assert error_text in caplog.text + + +async def test_disk_util( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the disk failures.""" + + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), # Should be ok + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), # Should be ok + sdiskpart("test3", "/incorrect", "", "", 1, 1), # Should be skipped as no type + sdiskpart( + "proc", "/proc/run", "proc", "", 1, 1 + ), # Should be skipped as in skipped disk types + sdiskpart( + "test4", + "/tmpfs/", # noqa: S108 + "tmpfs", + "", + 1, + 1, + ), # Should be skipped as in skipped disk types + sdiskpart("test5", "E:", "cd", "cdrom", 1, 1), # Should be skipped as cdrom + ] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor1 = hass.states.get("sensor.system_monitor_disk_free") + disk_sensor2 = hass.states.get("sensor.system_monitor_disk_free_media_share") + disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_incorrect") + disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_proc_run") + disk_sensor5 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") + disk_sensor6 = hass.states.get("sensor.system_monitor_disk_free_e") + assert disk_sensor1 is not None + assert disk_sensor2 is not None + assert disk_sensor3 is None + assert disk_sensor4 is None + assert disk_sensor5 is None + assert disk_sensor6 is None diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index fd4ae87ac64..91bc1af191e 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -24,7 +24,7 @@ async def test_air_con(hass: HomeAssistant) -> None: "min_temp": 16.0, "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], - "supported_features": 25, + "supported_features": 409, "target_temp_step": 1, "temperature": 17.8, } @@ -51,7 +51,7 @@ async def test_heater(hass: HomeAssistant) -> None: "min_temp": 16.0, "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], - "supported_features": 17, + "supported_features": 401, "target_temp_step": 1, "temperature": 20.5, } @@ -81,7 +81,7 @@ async def test_smartac_with_swing(hass: HomeAssistant) -> None: "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], "swing_modes": ["on", "off"], - "supported_features": 57, + "supported_features": 441, "target_temp_step": 1.0, "temperature": 20.0, } diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py new file mode 100644 index 00000000000..011dcf5e7bd --- /dev/null +++ b/tests/components/tankerkoenig/conftest.py @@ -0,0 +1,75 @@ +"""Fixtures for Tankerkoenig integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +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 homeassistant.setup import async_setup_component + +from .const import NEARBY_STATIONS, PRICES, STATION + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="tankerkoenig") +def mock_tankerkoenig() -> Generator[AsyncMock, None, None]: + """Mock the aiotankerkoenig client.""" + with patch( + "homeassistant.components.tankerkoenig.coordinator.Tankerkoenig", + autospec=True, + ) as mock_tankerkoenig, patch( + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig", + new=mock_tankerkoenig, + ): + mock = mock_tankerkoenig.return_value + mock.station_details.return_value = STATION + mock.prices.return_value = PRICES + mock.nearby_stations.return_value = NEARBY_STATIONS + 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="Mock Title", + unique_id="51.0_13.0", + entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", + options={ + CONF_SHOW_ON_MAP: True, + }, + 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", + ], + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, tankerkoenig: 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/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py new file mode 100644 index 00000000000..9ec64eb79a9 --- /dev/null +++ b/tests/components/tankerkoenig/const.py @@ -0,0 +1,59 @@ +"""Constants for the Tankerkoenig tests.""" + +from aiotankerkoenig import PriceInfo, Station, Status + +NEARBY_STATIONS = [ + Station( + id="3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + brand="BrandA", + place="CityA", + street="Main", + house_number="1", + distance=1, + lat=51.1, + lng=13.1, + name="Station ABC", + post_code=1234, + ), + Station( + id="36b4b812-xxxx-xxxx-xxxx-c51735325858", + brand="BrandB", + place="CityB", + street="School", + house_number="2", + distance=2, + lat=51.2, + lng=13.2, + name="Station DEF", + post_code=2345, + ), +] + +STATION = Station( + id="3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + name="Station ABC", + brand="Station", + street="Somewhere Street", + house_number="1", + post_code=1234, + place="Somewhere", + opening_times=[], + overrides=[], + whole_day=True, + is_open=True, + e5=1.719, + e10=1.659, + diesel=1.659, + lat=51.1, + lng=13.1, + state="xxXX", +) + +PRICES = { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": PriceInfo( + status=Status.OPEN, + e5=1.719, + e10=1.659, + diesel=1.659, + ), +} diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index da34cf66894..db3d0aac222 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for Tankerkoenig config flow.""" from unittest.mock import patch -from pytankerkoenig import customException +from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError from homeassistant.components.tankerkoenig.const import ( CONF_FUEL_TYPES, @@ -21,6 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import NEARBY_STATIONS + from tests.common import MockConfigEntry MOCK_USER_DATA = { @@ -47,28 +49,6 @@ MOCK_OPTIONS_DATA = { ], } -MOCK_NEARVY_STATIONS_OK = { - "ok": True, - "stations": [ - { - "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "brand": "BrandA", - "place": "CityA", - "street": "Main", - "houseNumber": "1", - "dist": 1, - }, - { - "id": "36b4b812-xxxx-xxxx-xxxx-c51735325858", - "brand": "BrandB", - "place": "CityB", - "street": "School", - "houseNumber": "2", - "dist": 2, - }, - ], -} - async def test_user(hass: HomeAssistant) -> None: """Test starting a flow by user.""" @@ -81,8 +61,8 @@ async def test_user(hass: HomeAssistant) -> None: with patch( "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value=MOCK_NEARVY_STATIONS_OK, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=NEARBY_STATIONS, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -143,8 +123,8 @@ async def test_exception_security(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - side_effect=customException, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + side_effect=TankerkoenigInvalidKeyError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -163,8 +143,8 @@ async def test_user_no_stations(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value={"ok": True, "stations": []}, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=[], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -174,32 +154,26 @@ async def test_user_no_stations(hass: HomeAssistant) -> None: assert result["errors"][CONF_RADIUS] == "no_stations" -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test starting a flow by user to re-auth.""" - - mock_config = MockConfigEntry( - domain=DOMAIN, - data={**MOCK_USER_DATA, **MOCK_STATIONS_DATA}, - unique_id=f"{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", - ) - mock_config.add_to_hass(hass) + config_entry.add_to_hass(hass) with patch( "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", ) as mock_nearby_stations: # re-auth initialized result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, - data=mock_config.data, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" # re-auth unsuccessful - mock_nearby_stations.return_value = {"ok": False} + mock_nearby_stations.side_effect = TankerkoenigInvalidKeyError("Booom!") result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -211,7 +185,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_API_KEY: "invalid_auth"} # re-auth successful - mock_nearby_stations.return_value = MOCK_NEARVY_STATIONS_OK + mock_nearby_stations.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -223,7 +197,7 @@ async def test_reauth(hass: HomeAssistant) -> None: mock_setup_entry.assert_called() - entry = hass.config_entries.async_get_entry(mock_config.entry_id) + entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert entry.data[CONF_API_KEY] == "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx" @@ -239,24 +213,52 @@ async def test_options_flow(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) with patch( - "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value=MOCK_NEARVY_STATIONS_OK, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=NEARBY_STATIONS, ): - await hass.config_entries.async_setup(mock_config.entry_id) - await hass.async_block_till_done() - assert mock_setup_entry.called - result = await hass.config_entries.options.async_init(mock_config.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_SHOW_ON_MAP: False, - CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], - }, + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert not mock_config.options[CONF_SHOW_ON_MAP] + + +async def test_options_flow_error(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_OPTIONS_DATA, + options={CONF_SHOW_ON_MAP: True}, + unique_id=f"{DOMAIN}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert not mock_config.options[CONF_SHOW_ON_MAP] + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + side_effect=TankerkoenigInvalidKeyError("Booom!"), + ) as mock_nearby_stations: + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_auth"} + + mock_nearby_stations.return_value = NEARBY_STATIONS + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert not mock_config.options[CONF_SHOW_ON_MAP] diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py index 59f273683a2..8d7137c503a 100644 --- a/tests/components/tankerkoenig/test_diagnostics.py +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -1,103 +1,23 @@ """Tests for the Tankerkoening integration.""" from __future__ import annotations -from unittest.mock import patch - +import pytest 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, - }, - }, -} - +@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.""" - 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) - + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot diff --git a/tests/components/technove/__init__.py b/tests/components/technove/__init__.py new file mode 100644 index 00000000000..2d9f639244f --- /dev/null +++ b/tests/components/technove/__init__.py @@ -0,0 +1,17 @@ +"""Tests for the TechnoVE integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the TechnoVE integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.technove.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/technove/conftest.py b/tests/components/technove/conftest.py new file mode 100644 index 00000000000..b3921f865dc --- /dev/null +++ b/tests/components/technove/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for TechnoVE integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from technove import Station as TechnoVEStation + +from homeassistant.components.technove.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="AA:AA:AA:AA:AA:BB", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.technove.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_onboarding() -> Generator[MagicMock, None, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + +@pytest.fixture +def device_fixture() -> TechnoVEStation: + """Return the device fixture for a specific device.""" + return TechnoVEStation(load_json_object_fixture("station_charging.json", DOMAIN)) + + +@pytest.fixture +def mock_technove(device_fixture: TechnoVEStation) -> Generator[MagicMock, None, None]: + """Return a mocked TechnoVE client.""" + with patch( + "homeassistant.components.technove.coordinator.TechnoVE", autospec=True + ) as technove_mock, patch( + "homeassistant.components.technove.config_flow.TechnoVE", new=technove_mock + ): + technove = technove_mock.return_value + technove.update.return_value = device_fixture + technove.ip_address = "127.0.0.1" + yield technove + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> MockConfigEntry: + """Set up the TechnoVE 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/technove/fixtures/station_charging.json b/tests/components/technove/fixtures/station_charging.json new file mode 100644 index 00000000000..ea98dc0b071 --- /dev/null +++ b/tests/components/technove/fixtures/station_charging.json @@ -0,0 +1,27 @@ +{ + "voltageIn": 238, + "voltageOut": 238, + "maxStationCurrent": 32, + "maxCurrent": 24, + "current": 23.75, + "network_ssid": "Connecting...", + "id": "AA:AA:AA:AA:AA:BB", + "auto_charge": true, + "highChargePeriodActive": false, + "normalPeriodActive": false, + "maxChargePourcentage": 0.9, + "isBatteryProtected": false, + "inSharingMode": true, + "energySession": 12.34, + "energyTotal": 1234, + "version": "1.82", + "rssi": -82, + "name": "TechnoVE Station", + "lastCharge": "1701072080,0,17.39\n", + "time": 1701000000, + "isUpToDate": true, + "isSessionActive": true, + "conflictInSharingConfig": false, + "isStaticIp": false, + "status": 67 +} diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1b54bdda2ce --- /dev/null +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -0,0 +1,261 @@ +# serializer version: 1 +# name: test_sensors[binary_sensor.technove_station_battery_protected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_battery_protected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery protected', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_battery_protected', + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_battery_protected', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_battery_protected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Battery protected', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_battery_protected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_session_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'TechnoVE Station Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_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': , + 'entity_id': 'binary_sensor.technove_station_conflict_with_power_sharing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conflict with power sharing mode', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'conflict_in_sharing_config', + 'unique_id': 'AA:AA:AA:AA:AA:BB_conflict_in_sharing_config', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Conflict with power sharing mode', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_conflict_with_power_sharing_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_power_sharing_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': , + 'entity_id': 'binary_sensor.technove_station_power_sharing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power sharing mode', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'in_sharing_mode', + 'unique_id': 'AA:AA:AA:AA:AA:BB_in_sharing_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_power_sharing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Power sharing mode', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_power_sharing_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_static_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_static_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Static IP', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_static_ip', + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_static_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_static_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Static IP', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_static_ip', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'TechnoVE Station Update', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_update', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d38b08631cc --- /dev/null +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -0,0 +1,436 @@ +# serializer version: 1 +# name: test_sensors[sensor.technove_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_current', + 'last_changed': , + 'last_updated': , + 'state': '23.75', + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_in', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_in', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last session energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_session', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_session', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Last session energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.34', + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max station current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_station_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_station_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max station current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_out', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_out', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Output voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TechnoVE Station Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'last_changed': , + 'last_updated': , + 'state': '-82', + }) +# --- +# name: test_sensors[sensor.technove_station_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'AA:AA:AA:AA:AA:BB_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'TechnoVE Station Status', + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.technove_station_status', + 'last_changed': , + 'last_updated': , + 'state': 'plugged_charging', + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Total energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + '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 network name', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': 'AA:AA:AA:AA:AA:BB_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Wi-Fi network name', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'last_changed': , + 'last_updated': , + 'state': 'Connecting...', + }) +# --- diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py new file mode 100644 index 00000000000..5e168ce0760 --- /dev/null +++ b/tests/components/technove/test_binary_sensor.py @@ -0,0 +1,75 @@ +"""Tests for the TechnoVE binary sensor platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from technove import TechnoVEError + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE binary sensors.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + + 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" + ) + + +@pytest.mark.parametrize( + "entity_id", + ("binary_sensor.technove_station_static_ip",), +) +@pytest.mark.usefixtures("init_integration") +async def test_disabled_by_default_binary_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> None: + """Test the disabled by default TechnoVE binary sensors.""" + assert hass.states.get(entity_id) is None + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("init_integration") +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_technove: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update failure.""" + entity_id = "binary_sensor.technove_station_charging" + + assert hass.states.get(entity_id).state == STATE_ON + + mock_technove.update.side_effect = TechnoVEError("Test error") + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py new file mode 100644 index 00000000000..72b9b358c89 --- /dev/null +++ b/tests/components/technove/test_config_flow.py @@ -0,0 +1,266 @@ +"""Tests for the TechnoVE config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock + +import pytest +from technove import TechnoVEConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.technove.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_technove") +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test we abort the config flow if TechnoVE station is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_connection_error(hass: HomeAssistant, mock_technove: MagicMock) -> None: + """Test we show user form on TechnoVE connection error.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.com"}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_with_error( + hass: HomeAssistant, mock_technove: MagicMock +) -> None: + """Test the full manual user flow from start to finish with some errors in the middle.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + mock_technove.update.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user 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("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + assert result.get("description_placeholders") == {CONF_NAME: "TechnoVE Station"} + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2.get("title") == "TechnoVE Station" + assert result2.get("type") == FlowResultType.CREATE_ENTRY + + assert "data" in result2 + assert result2["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result2 + assert result2["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test we create a config entry when discovered during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + + assert result.get("data") == {CONF_HOST: "192.168.1.123"} + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_technove: MagicMock +) -> None: + """Test we abort zeroconf flow on TechnoVE connection error.""" + mock_technove.update.side_effect = TechnoVEConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +@pytest.mark.usefixtures("mock_technove") +async def test_user_station_exists_abort( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_without_mac_station_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_with_mac_station_exists_abort( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_technove: MagicMock +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + mock_technove.update.assert_not_called() + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/technove/test_init.py b/tests/components/technove/test_init.py new file mode 100644 index 00000000000..0b5d68e405d --- /dev/null +++ b/tests/components/technove/test_init.py @@ -0,0 +1,36 @@ +"""Tests for the TechnoVE integration.""" + +from unittest.mock import MagicMock + +from technove import TechnoVEConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a successful setup entry and unload.""" + + init_integration.add_to_hass(hass) + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_connection_error( + hass: HomeAssistant, + mock_technove: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a connection error after setup.""" + mock_technove.update.side_effect = TechnoVEConnectionError + 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 diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py new file mode 100644 index 00000000000..5215f62c517 --- /dev/null +++ b/tests/components/technove/test_sensor.py @@ -0,0 +1,95 @@ +"""Tests for the TechnoVE sensor platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from technove import Status, TechnoVEError + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE sensors.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + 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" + ) + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.technove_station_signal_strength", + "sensor.technove_station_wi_fi_network_name", + ), +) +@pytest.mark.usefixtures("init_integration") +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> None: + """Test the disabled by default TechnoVE sensors.""" + assert hass.states.get(entity_id) is None + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_wifi_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test missing Wi-Fi information from TechnoVE device.""" + # Remove Wi-Fi info + device = mock_technove.update.return_value + device.info.network_ssid = None + + # Setup + 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 (state := hass.states.get("sensor.technove_station_wi_fi_network_name")) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_update_failure( + hass: HomeAssistant, + mock_technove: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update failure.""" + entity_id = "sensor.technove_station_status" + + assert hass.states.get(entity_id).state == Status.PLUGGED_CHARGING.value + + mock_technove.update.side_effect = TechnoVEError("Test error") + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py new file mode 100644 index 00000000000..a72b1fbdd6a --- /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 00000000000..21fb4047ab3 --- /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 00000000000..6a8eb77d7ee --- /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 00000000000..a632ea3d57b --- /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', + }) +# --- \ No newline at end of file diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..401c519c215 --- /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 00000000000..2a89b1fe7ef --- /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, + }) +# --- \ No newline at end of file diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..dd0eab46c90 --- /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 00000000000..a74ee38bff0 --- /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 00000000000..ee8c318d2dd --- /dev/null +++ b/tests/components/tedee/test_binary_sensor.py @@ -0,0 +1,61 @@ +"""Tests for the Tedee Binary Sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock +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 + +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}") + + +async def test_new_binary_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure binary sensors for new lock are added automatically.""" + + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_4e5f_{key}") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_4e5f_{key}") + assert state diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py new file mode 100644 index 00000000000..bc5b73aa4a9 --- /dev/null +++ b/tests/components/tedee/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test the Tedee config flow.""" +from unittest.mock import MagicMock + +from pytedee_async import ( + TedeeClientException, + TedeeDataUpdateException, + 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"}, + ), + (TedeeDataUpdateException("boom."), {"base": "cannot_connect"}), + ], +) +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" diff --git a/tests/components/tedee/test_diagnostics.py b/tests/components/tedee/test_diagnostics.py new file mode 100644 index 00000000000..9a31e153b6c --- /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 00000000000..ca64c01a983 --- /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 00000000000..fca1ae2b07f --- /dev/null +++ b/tests/components/tedee/test_lock.py @@ -0,0 +1,268 @@ +"""Tests for tedee lock.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock +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 MockConfigEntry, 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 + + +async def test_cleanup_removed_locks( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure removed locks are cleaned up.""" + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + locks = [device.name for device in devices] + assert "Lock-1A2B" in locks + + # remove a lock and wait for coordinator + mock_tedee.locks_dict.pop(12345) + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + locks = [device.name for device in devices] + assert "Lock-1A2B" not in locks + + +async def test_new_lock( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure new lock is added automatically.""" + + state = hass.states.get("lock.lock_4e5f") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + mock_tedee.locks_dict[777777] = TedeeLock( + "Lock-6G7H", + 777777, + 4, + is_enabled_pullspring=True, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("lock.lock_4e5f") + assert state + state = hass.states.get("lock.lock_6g7h") + assert state diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py new file mode 100644 index 00000000000..274048082c0 --- /dev/null +++ b/tests/components/tedee/test_sensor.py @@ -0,0 +1,63 @@ +"""Tests for the Tedee Sensors.""" + + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock +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 + +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}") + + +async def test_new_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure sensors for new lock are added automatically.""" + + for key in SENSORS: + state = hass.states.get(f"sensor.lock_4e5f_{key}") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for key in SENSORS: + state = hass.states.get(f"sensor.lock_4e5f_{key}") + assert state diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 01c0f005716..708571ce913 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -764,7 +764,7 @@ async def test_no_update_template_match_all( ) -> None: """Test that we do not update sensors that match on all.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) await setup.async_setup_component( hass, diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 0ca666d22f1..314218fc849 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,6 +1,6 @@ """The test for the Template sensor platform.""" from asyncio import Event -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import ANY, patch import pytest @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.bootstrap import async_from_config_dict from homeassistant.components import sensor, template +from homeassistant.components.template.sensor import TriggerSensorEntity from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_ICON, @@ -507,7 +508,7 @@ async def test_no_template_match_all( """Test that we allow static templates.""" hass.states.async_set("sensor.test_sensor", "startup") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) await async_setup_component( hass, @@ -752,7 +753,7 @@ async def test_this_variable_early_hass_not_running( """ entity_id = "sensor.none_false" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) # Setup template with assert_setup_component(count, domain): @@ -818,7 +819,7 @@ async def test_this_variable_early_hass_running( """ # Start hass - assert hass.state == CoreState.running + assert hass.state is CoreState.running await hass.async_start() await hass.async_block_till_done() @@ -1456,6 +1457,212 @@ async def test_trigger_entity_device_class_errors_works(hass: HomeAssistant) -> assert ts_state.state == STATE_UNKNOWN +async def test_entity_last_reset_total_increasing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset is disallowed for total_increasing state_class.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "TotalIncreasing entity", + "state": "{{ 0 }}", + "state_class": "total_increasing", + "last_reset": "{{ today_at('00:00:00')}}", + }, + ], + }, + ], + }, + ) + await hass.async_block_till_done() + + totalincreasing_state = hass.states.get("sensor.totalincreasing_entity") + assert totalincreasing_state is None + + assert ( + "last_reset is only valid for template sensors with state_class 'total'" + in caplog.text + ) + + +async def test_entity_last_reset_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset works for template sensors.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Total entity", + "state": "{{ states('sensor.test_state') | int(0) + 1 }}", + "state_class": "total", + "last_reset": "{{ now() }}", + }, + { + "name": "Static last_reset entity", + "state": "{{ states('sensor.test_state') | int(0) }}", + "state_class": "total", + "last_reset": "2023-01-01T00:00:00", + }, + ], + }, + { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + ], + }, + "sensor": { + "name": "Total trigger entity", + "state": "{{ states('sensor.test_state') | int(0) + 2 }}", + "state_class": "total", + "last_reset": "{{ as_datetime('2023-01-01') }}", + }, + }, + ], + }, + ) + await hass.async_block_till_done() + + # Trigger update + hass.states.async_set("sensor.test_state", "0") + await hass.async_block_till_done() + await hass.async_block_till_done() + + static_state = hass.states.get("sensor.static_last_reset_entity") + assert static_state is not None + assert static_state.state == "0" + assert static_state.attributes.get("state_class") == "total" + assert ( + static_state.attributes.get("last_reset") + == datetime(2023, 1, 1, 0, 0, 0).isoformat() + ) + + total_state = hass.states.get("sensor.total_entity") + assert total_state is not None + assert total_state.state == "1" + assert total_state.attributes.get("state_class") == "total" + assert total_state.attributes.get("last_reset") == now.isoformat() + + total_trigger_state = hass.states.get("sensor.total_trigger_entity") + assert total_trigger_state is not None + assert total_trigger_state.state == "2" + assert total_trigger_state.attributes.get("state_class") == "total" + assert ( + total_trigger_state.attributes.get("last_reset") + == datetime(2023, 1, 1).isoformat() + ) + + +async def test_entity_last_reset_static_value(hass: HomeAssistant) -> None: + """Test static last_reset marked as static_rendered.""" + + tse = TriggerSensorEntity( + hass, + None, + { + "name": Template("Static last_reset entity", hass), + "state": Template("{{ states('sensor.test_state') | int(0) }}", hass), + "state_class": "total", + "last_reset": Template("2023-01-01T00:00:00", hass), + }, + ) + + assert "last_reset" in tse._static_rendered + + +async def test_entity_last_reset_parsing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset works for template sensors.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch( + "homeassistant.components.template.sensor._LOGGER.warning" + ) as mocked_warning, patch( + "homeassistant.components.template.template_entity._LOGGER.error" + ) as mocked_error, patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Total entity", + "state": "{{ states('sensor.test_state') | int(0) + 1 }}", + "state_class": "total", + "last_reset": "{{ 'not a datetime' }}", + }, + ], + }, + { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + ], + }, + "sensor": { + "name": "Total trigger entity", + "state": "{{ states('sensor.test_state') | int(0) + 2 }}", + "state_class": "total", + "last_reset": "{{ 'not a datetime' }}", + }, + }, + ], + }, + ) + await hass.async_block_till_done() + + # Trigger update + hass.states.async_set("sensor.test_state", "0") + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Trigger based datetime parsing warning: + mocked_warning.assert_called_once_with( + "%s rendered invalid timestamp for last_reset attribute: %s", + "sensor.total_trigger_entity", + "not a datetime", + ) + + # State based datetime parsing error + mocked_error.assert_called_once() + args, _ = mocked_error.call_args + assert len(args) == 6 + assert args[0] == ( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'" + ) + assert args[1] == "not a datetime" + assert args[3] == "_attr_last_reset" + assert args[4] == "sensor.total_entity" + assert args[5] == "Invalid datetime specified: not a datetime" + + async def test_entity_device_class_parsing_works(hass: HomeAssistant) -> None: """Test entity device class parsing works.""" # State of timestamp sensors are always in UTC diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index db09fe6e676..1e979fc9926 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -525,7 +525,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component( diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 0cafc15c6f1..28b50ba72ea 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -13,7 +13,9 @@ async def test_sensors(hass: HomeAssistant) -> None: """Test all sensors.""" entity_and_expected_values = [ - EntityAndExpectedValues("sensor.tesla_wall_connector_state", "1", "2"), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_status", "not_connected", "unknown" + ), EntityAndExpectedValues( "sensor.tesla_wall_connector_handle_temperature", "25.5", "-1.4" ), @@ -24,7 +26,7 @@ async def test_sensors(hass: HomeAssistant) -> None: "sensor.tesla_wall_connector_grid_frequency", "50.021", "49.981" ), EntityAndExpectedValues( - "sensor.tesla_wall_connector_energy", "988022", "989000" + "sensor.tesla_wall_connector_energy", "988.022", "989.000" ), EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_a_current", "10", "7" @@ -44,6 +46,9 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_c_voltage", "232.1", "230" ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_session_energy", "1.23456", "0.1122" + ), ] mock_vitals_first_update = get_vitals_mock() @@ -57,9 +62,10 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_first_update.currentA_a = 10 mock_vitals_first_update.currentB_a = 11.1 mock_vitals_first_update.currentC_a = 12 + mock_vitals_first_update.session_energy_wh = 1234.56 mock_vitals_second_update = get_vitals_mock() - mock_vitals_second_update.evse_state = 2 + mock_vitals_second_update.evse_state = 3 mock_vitals_second_update.handle_temp_c = -1.42 mock_vitals_second_update.grid_v = 229.21 mock_vitals_second_update.grid_hz = 49.981 @@ -69,6 +75,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.currentA_a = 7 mock_vitals_second_update.currentB_a = 8 mock_vitals_second_update.currentC_a = 9 + mock_vitals_second_update.session_energy_wh = 112.2 lifetime_mock_first_update = get_lifetime_mock() lifetime_mock_first_update.energy_wh = 988022 diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py new file mode 100644 index 00000000000..eae58127d1d --- /dev/null +++ b/tests/components/teslemetry/__init__.py @@ -0,0 +1,50 @@ +"""Tests for the Teslemetry integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONFIG + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): + """Set up the Teslemetry platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + ) + mock_entry.add_to_hass(hass) + + if platforms is None: + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry + + +def assert_entities( + hass: HomeAssistant, + entry_id: str, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py new file mode 100644 index 00000000000..0fc279eaa21 --- /dev/null +++ b/tests/components/teslemetry/conftest.py @@ -0,0 +1,47 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .const import PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE + + +@pytest.fixture(autouse=True) +def mock_products(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + ) as mock_products: + yield mock_products + + +@pytest.fixture(autouse=True) +def mock_vehicle_data(): + """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + return_value=VEHICLE_DATA, + ) as mock_vehicle_data: + yield mock_vehicle_data + + +@pytest.fixture(autouse=True) +def mock_wake_up(): + """Mock Tesla Fleet API Vehicle Specific wake_up method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + return_value=WAKE_UP_ONLINE, + ) as mock_wake_up: + yield mock_wake_up + + +@pytest.fixture(autouse=True) +def mock_request(): + """Mock Tesla Fleet API Vehicle Specific class.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry._request", + return_value=RESPONSE_OK, + ) as mock_request: + yield mock_request diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py new file mode 100644 index 00000000000..0feb056fa72 --- /dev/null +++ b/tests/components/teslemetry/const.py @@ -0,0 +1,16 @@ +"""Constants for the teslemetry tests.""" + +from homeassistant.components.teslemetry.const import DOMAIN, TeslemetryState +from homeassistant.const import CONF_ACCESS_TOKEN + +from tests.common import load_json_object_fixture + +CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} + +WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} +WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} + +PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) + +RESPONSE_OK = {"response": {}, "error": None} diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json new file mode 100644 index 00000000000..430c3b39dc8 --- /dev/null +++ b/tests/components/teslemetry/fixtures/products.json @@ -0,0 +1,99 @@ +{ + "response": [ + { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "display_name": "Test", + "option_codes": null, + "cached_data": null, + "granular_access": { "hide_private": false }, + "tokens": ["abc", "def"], + "state": "asleep", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "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": true, + "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": 1705701487912, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "command_signing": "allowed", + "release_notes_supported": true + }, + { + "energy_site_id": 2345, + "resource_type": "wall_connector", + "id": "ID1234", + "asset_site_id": "abcdef", + "warp_site_number": "ID1234", + "go_off_grid_test_banner_enabled": null, + "storm_mode_enabled": null, + "powerwall_onboarding_settings_set": null, + "powerwall_tesla_electric_interested_in": null, + "vpp_tour_enabled": null, + "sync_grid_alert_enabled": false, + "breaker_alert_enabled": false, + "components": { + "battery": false, + "solar": false, + "grid": false, + "load_meter": false, + "wall_connectors": [ + { "device_id": "abcdef", "din": "12345", "is_active": true } + ] + }, + "features": {} + } + ], + "count": 2 +} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json new file mode 100644 index 00000000000..44556c1c8df --- /dev/null +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -0,0 +1,269 @@ +{ + "response": { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["abc", "def"], + "state": "online", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 77, + "battery_range": 266.87, + "charge_amps": 16, + "charge_current_request": 16, + "charge_current_request_max": 16, + "charge_enable_request": true, + "charge_energy_added": 0, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 0, + "charge_miles_added_rated": 0, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 0, + "charger_actual_current": 0, + "charger_phases": null, + "charger_pilot_current": 16, + "charger_power": 0, + "charger_voltage": 2, + "charging_state": "Stopped", + "conn_charge_cable": "IEC", + "est_battery_range": 275.04, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 266.87, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 0, + "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": "Off", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": null, + "scheduled_charging_start_time_app": 600, + "scheduled_departure_time": 1704837600, + "scheduled_departure_time_minutes": 480, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0, + "timestamp": 1705707520649, + "trip_charging": false, + "usable_battery_level": 77, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": false, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": false, + "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, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 29.8, + "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": 251, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30, + "passenger_temp_setting": 22, + "remote_heater_control_enabled": false, + "right_temp_direction": 251, + "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": 1705707520649, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": -27.855946, + "active_route_longitude": 153.345056, + "active_route_traffic_minutes_delay": 0, + "power": 0, + "shift_state": null, + "speed": null, + "timestamp": 1705707520649 + }, + "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": 1705707520649 + }, + "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": true, + "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": 1705707520649, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 71, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.44.30.8 06f534d46010", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,187f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": false, + "media_info": { + "audio_volume": 2.6667, + "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": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": true + }, + "notifications_supported": true, + "odometer": 6481.019282, + "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": 69, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1705707520649, + "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": 1705700812, + "tpms_last_seen_pressure_time_fr": 1705700793, + "tpms_last_seen_pressure_time_rl": 1705700794, + "tpms_last_seen_pressure_time_rr": 1705700823, + "tpms_pressure_fl": 2.775, + "tpms_pressure_fr": 2.8, + "tpms_pressure_rl": 2.775, + "tpms_pressure_rr": 2.775, + "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 + } + } +} diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr new file mode 100644 index 00000000000..cba5b05eff2 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'VINVINVIN-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py new file mode 100644 index 00000000000..ede38a695e2 --- /dev/null +++ b/tests/components/teslemetry/test_climate.py @@ -0,0 +1,131 @@ +"""Test the Teslemetry climate platform.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + 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.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform + +from tests.common import async_fire_time_changed + + +async def test_climate( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the climate entity is correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL + + # Set Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 + + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "keep"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "keep" + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + +async def test_errors( + hass: HomeAssistant, +) -> None: + """Tests service error is handled.""" + + await setup_platform(hass, platforms=[Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + side_effect=InvalidCommand, + ) as mock_on, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + assert error.from_exception == InvalidCommand + + +async def test_asleep_or_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Tests asleep is handled.""" + + await setup_platform(hass, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + mock_vehicle_data.assert_called_once() + + # Put the vehicle alseep + mock_vehicle_data.reset_mock() + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + # Run a command that will wake up the vehicle, but not immediately + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True + ) + await hass.async_block_till_done() diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py new file mode 100644 index 00000000000..b89967bfa35 --- /dev/null +++ b/tests/components/teslemetry/test_config_flow.py @@ -0,0 +1,84 @@ +"""Test the Teslemetry config flow.""" + +from unittest.mock import patch + +from aiohttp import ClientConnectionError +import pytest +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError + +from homeassistant import config_entries +from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CONFIG + + +@pytest.fixture(autouse=True) +def mock_test(): + """Mock Teslemetry api class.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry.test", return_value=True + ) as mock_test: + yield mock_test + + +async def test_form( + hass: HomeAssistant, +) -> 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.teslemetry.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (InvalidToken, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (PaymentRequired, {"base": "subscription_required"}), + (ClientConnectionError, {"base": "cannot_connect"}), + (TeslaFleetError, {"base": "unknown"}), + ], +) +async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) -> None: + """Test errors are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_test.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_test.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py new file mode 100644 index 00000000000..28440094bec --- /dev/null +++ b/tests/components/teslemetry/test_init.py @@ -0,0 +1,118 @@ +"""Test the Tessie init.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from tesla_fleet_api.exceptions import ( + InvalidToken, + PaymentRequired, + TeslaFleetError, + VehicleOffline, +) + +from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform +from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE + +from tests.common import async_fire_time_changed + + +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, mock_products) -> None: + """Test init with an authentication error.""" + + mock_products.side_effect = InvalidToken + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_subscription_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = PaymentRequired + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_other_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +# Coordinator + + +async def test_first_refresh( + hass: HomeAssistant, + mock_wake_up, + mock_vehicle_data, + mock_products, + freezer: FrozenDateTimeFactory, +) -> None: + """Test first coordinator refresh but vehicle is asleep.""" + + # Mock vehicle is asleep + mock_wake_up.return_value = WAKE_UP_ASLEEP + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + mock_wake_up.assert_called_once() + + # Reset mock and set vehicle to online + mock_wake_up.reset_mock() + mock_wake_up.return_value = WAKE_UP_ONLINE + + # Wait for the retry + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify we have loaded + assert entry.state is ConfigEntryState.LOADED + mock_wake_up.assert_called_once() + mock_vehicle_data.assert_called_once() + + +async def test_first_refresh_error(hass: HomeAssistant, mock_wake_up) -> None: + """Test first coordinator refresh with an error.""" + mock_wake_up.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_refresh_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Test coordinator refresh with an error.""" + entry = await setup_platform(hass, [Platform.CLIMATE]) + assert entry.state is ConfigEntryState.LOADED + mock_vehicle_data.assert_called_once() + mock_vehicle_data.reset_mock() + + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + +async def test_refresh_error(hass: HomeAssistant, mock_vehicle_data) -> None: + """Test coordinator refresh with an error.""" + mock_vehicle_data.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index ccff7f62b1b..c57dbda8b53 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -5,10 +5,12 @@ from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo +from syrupy import SnapshotAssertion from homeassistant.components.tessie.const import DOMAIN, TessieStatus -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_json_object_fixture @@ -44,7 +46,9 @@ ERROR_VIRTUAL_KEY = ClientResponseError( ERROR_CONNECTION = ClientConnectionError() -async def setup_platform(hass: HomeAssistant, side_effect=None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] = [], side_effect=None +) -> MockConfigEntry: """Set up the Tessie platform.""" mock_entry = MockConfigEntry( @@ -57,8 +61,24 @@ async def setup_platform(hass: HomeAssistant, side_effect=None): "homeassistant.components.tessie.get_state_of_all_vehicles", return_value=TEST_STATE_OF_ALL_VEHICLES, side_effect=side_effect, - ): + ), patch("homeassistant.components.tessie.PLATFORMS", platforms): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() return mock_entry + + +def assert_entities( + hass: HomeAssistant, + entry_id: str, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index e150b9e60e7..359e23f9cdd 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -62,7 +62,7 @@ "fast_charger_type": "ACSingleWireCAN", "ideal_battery_range": 263.68, "max_range_charge_counter": 0, - "minutes_to_full_charge": 30, + "minutes_to_full_charge": 0, "not_enough_power_to_heat": null, "off_peak_charging_enabled": false, "off_peak_charging_times": "all_week", @@ -77,7 +77,7 @@ "scheduled_departure_time": 1694899800, "scheduled_departure_time_minutes": 450, "supercharger_session_trip_planner": false, - "time_to_full_charge": 0.5, + "time_to_full_charge": 0, "timestamp": 1701139037461, "trip_charging": false, "usable_battery_level": 75, @@ -127,6 +127,10 @@ "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_traffic_minutes_delay": 0, + "active_route_destination": "Giga Texas", + "active_route_energy_at_arrival": 65, + "active_route_miles_to_arrival": 46.707353, + "active_route_minutes_to_arrival": 59.2, "gps_as_of": 1701129612, "heading": 185, "latitude": -30.222626, diff --git a/tests/components/tessie/snapshots/test_binary_sensors.ambr b/tests/components/tessie/snapshots/test_binary_sensors.ambr new file mode 100644 index 00000000000..2fbd6764081 --- /dev/null +++ b/tests/components/tessie/snapshots/test_binary_sensors.ambr @@ -0,0 +1,1142 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_battery_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_battery_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Cabin overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection actively cooling', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charge_cable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge cable', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_conn_charge_cable', + 'unique_id': 'VINVINVIN-charge_state_conn_charge_cable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charge_cable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Charge cable', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_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': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charging_state', + 'unique_id': 'VINVINVIN-charge_state_charging_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_dashcam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_dashcam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dashcam', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_dashcam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_df', + 'unique_id': 'VINVINVIN-vehicle_state_df', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'VINVINVIN-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pf', + 'unique_id': 'VINVINVIN-vehicle_state_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'VINVINVIN-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_heat', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_preconditioning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning enabled', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dr', + 'unique_id': 'VINVINVIN-vehicle_state_dr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'VINVINVIN-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pr', + 'unique_id': 'VINVINVIN-vehicle_state_pr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'VINVINVIN-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_scheduled_charging_pending-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scheduled charging pending', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'VINVINVIN-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_trip_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'VINVINVIN-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_button.ambr b/tests/components/tessie/snapshots/test_button.ambr new file mode 100644 index 00000000000..5c3938eaddb --- /dev/null +++ b/tests/components/tessie/snapshots/test_button.ambr @@ -0,0 +1,265 @@ +# serializer version: 1 +# name: test_buttons[button.test_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flashlight', + 'original_name': 'Flash lights', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'VINVINVIN-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + 'icon': 'mdi:flashlight', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_homelink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_homelink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage', + 'original_name': 'Homelink', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trigger_homelink', + 'unique_id': 'VINVINVIN-trigger_homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + 'icon': 'mdi:garage', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_honk_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_honk_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bullhorn', + 'original_name': 'Honk horn', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'VINVINVIN-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_keyless_driving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_keyless_driving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car-key', + 'original_name': 'Keyless driving', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'VINVINVIN-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + 'icon': 'mdi:car-key', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_play_fart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_play_fart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Play fart', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'VINVINVIN-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + 'icon': 'mdi:volume-high', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_wake-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_wake', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Wake', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'VINVINVIN-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + 'icon': 'mdi:sleep-off', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0205df15705 --- /dev/null +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'primary', + 'unique_id': 'VINVINVIN-primary', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.5, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + 'temperature': 22.5, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr index ae5e95be68d..e95da1df3b9 100644 --- a/tests/components/tessie/snapshots/test_cover.ambr +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -1,5 +1,36 @@ # serializer version: 1 -# name: test_covers[cover.test_charge_port_door-open_unlock_charge_port-close_charge_port][cover.test_charge_port_door] +# name: test_covers[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'VINVINVIN-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_charge_port_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -13,7 +44,38 @@ 'state': 'open', }) # --- -# name: test_covers[cover.test_frunk-open_front_trunk-False][cover.test_frunk] +# name: test_covers[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'VINVINVIN-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_frunk-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -27,7 +89,38 @@ 'state': 'closed', }) # --- -# name: test_covers[cover.test_trunk-open_close_rear_trunk-open_close_rear_trunk][cover.test_trunk] +# name: test_covers[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'VINVINVIN-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_trunk-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -41,7 +134,38 @@ 'state': 'closed', }) # --- -# name: test_covers[cover.test_vent_windows-vent_windows-close_windows][cover.test_vent_windows] +# name: test_covers[cover.test_vent_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_vent_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vent windows', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'VINVINVIN-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_vent_windows-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'window', diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..ff47c73e8cd --- /dev/null +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.test_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'VINVINVIN-location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'heading': 185, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + 'speed': None, + }), + 'context': , + 'entity_id': 'device_tracker.test_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[device_tracker.test_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Route', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'route', + 'unique_id': 'VINVINVIN-route', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_route', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_lock.ambr b/tests/components/tessie/snapshots/test_lock.ambr new file mode 100644 index 00000000000..cef92a1226f --- /dev/null +++ b/tests/components/tessie/snapshots/test_lock.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_locks[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'VINVINVIN-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_locks[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'VINVINVIN-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index e4c7f37c4ce..c3747174a4a 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -1,5 +1,37 @@ # serializer version: 1 -# name: test_media_player_idle +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-paused] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', @@ -14,7 +46,7 @@ 'state': 'idle', }) # --- -# name: test_media_player_idle.1 +# name: test_media_player[media_player.test_media_player-playing] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', @@ -29,33 +61,3 @@ '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/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr new file mode 100644 index 00000000000..23ecbbfabbe --- /dev/null +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -0,0 +1,163 @@ +# serializer version: 1 +# name: test_numbers[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'VINVINVIN-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_numbers[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'VINVINVIN-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_numbers[number.test_speed_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 120, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_speed_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed limit', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_speed_limit_mode_current_limit_mph', + 'unique_id': 'VINVINVIN-vehicle_state_speed_limit_mode_current_limit_mph', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.test_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Speed limit', + 'max': 120, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_speed_limit', + 'last_changed': , + 'last_updated': , + 'state': '74.564543', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr new file mode 100644 index 00000000000..7a6978e3aef --- /dev/null +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -0,0 +1,299 @@ +# serializer version: 1 +# name: test_select[select.test_seat_heater_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_left', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear center', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_center', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_center', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear center', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_center', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_left', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_right', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear right', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_right', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater right', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select_option] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_left', + 'last_changed': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2f5e1e8ddb2 --- /dev/null +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -0,0 +1,1259 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_usable_battery_level', + 'unique_id': 'VINVINVIN-charge_state_usable_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensors[sensor.test_battery_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_battery_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery range', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_range', + 'unique_id': 'VINVINVIN-charge_state_battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Battery range', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_range', + 'last_changed': , + 'last_updated': , + 'state': '424.35182592', + }) +# --- +# name: test_sensors[sensor.test_charge_energy_added-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_charge_energy_added', + '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': 'Charge energy added', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_energy_added', + 'unique_id': 'VINVINVIN-charge_state_charge_energy_added', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charge_energy_added-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Charge energy added', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charge_energy_added', + 'last_changed': , + 'last_updated': , + 'state': '18.47', + }) +# --- +# name: test_sensors[sensor.test_charge_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charge_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge rate', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_rate', + 'unique_id': 'VINVINVIN-charge_state_charge_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charge_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Charge rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charge_rate', + 'last_changed': , + 'last_updated': , + 'state': '49.2', + }) +# --- +# name: test_sensors[sensor.test_charger_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charger_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger current', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_actual_current', + 'unique_id': 'VINVINVIN-charge_state_charger_actual_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charger current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.test_charger_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_charger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_power', + 'unique_id': 'VINVINVIN-charge_state_charger_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Charger power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_power', + 'last_changed': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensors[sensor.test_charger_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charger_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger voltage', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_voltage', + 'unique_id': 'VINVINVIN-charge_state_charger_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test Charger voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_voltage', + 'last_changed': , + 'last_updated': , + 'state': '224', + }) +# --- +# name: test_sensors[sensor.test_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:map-marker', + 'original_name': 'Destination', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_destination', + 'unique_id': 'VINVINVIN-drive_state_active_route_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Destination', + 'icon': 'mdi:map-marker', + }), + 'context': , + 'entity_id': 'sensor.test_destination', + 'last_changed': , + 'last_updated': , + 'state': 'Giga Texas', + }) +# --- +# name: test_sensors[sensor.test_distance_to_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_distance_to_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_miles_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_distance_to_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Distance to arrival', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_distance_to_arrival', + 'last_changed': , + 'last_updated': , + 'state': '75.168198', + }) +# --- +# name: test_sensors[sensor.test_driver_temperature_setting-entry] + EntityRegistryEntrySnapshot({ + '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.test_driver_temperature_setting', + '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': 'Driver temperature setting', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_driver_temp_setting', + 'unique_id': 'VINVINVIN-climate_state_driver_temp_setting', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_driver_temperature_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Driver temperature setting', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_driver_temperature_setting', + 'last_changed': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensors[sensor.test_inside_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.test_inside_temperature', + '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': 'Inside temperature', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_inside_temp', + 'unique_id': 'VINVINVIN-climate_state_inside_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_inside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Inside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_inside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '30.4', + }) +# --- +# name: test_sensors[sensor.test_odometer-entry] + EntityRegistryEntrySnapshot({ + '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.test_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_odometer', + 'unique_id': 'VINVINVIN-vehicle_state_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_odometer', + 'last_changed': , + 'last_updated': , + 'state': '8778.15941765875', + }) +# --- +# name: test_sensors[sensor.test_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_outside_temperature', + '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': 'Outside temperature', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_outside_temp', + 'unique_id': 'VINVINVIN-climate_state_outside_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '30.5', + }) +# --- +# name: test_sensors[sensor.test_passenger_temperature_setting-entry] + EntityRegistryEntrySnapshot({ + '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.test_passenger_temperature_setting', + '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': 'Passenger temperature setting', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_passenger_temp_setting', + 'unique_id': 'VINVINVIN-climate_state_passenger_temp_setting', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_passenger_temperature_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Passenger temperature setting', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_passenger_temperature_setting', + 'last_changed': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensors[sensor.test_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_power', + 'unique_id': 'VINVINVIN-drive_state_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_power', + 'last_changed': , + 'last_updated': , + 'state': '-7', + }) +# --- +# name: test_sensors[sensor.test_shift_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'p', + 'd', + 'r', + 'n', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_shift_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:car-shift-pattern', + 'original_name': 'Shift state', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_shift_state', + 'unique_id': 'VINVINVIN-drive_state_shift_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_shift_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Shift state', + 'icon': 'mdi:car-shift-pattern', + 'options': list([ + 'p', + 'd', + 'r', + 'n', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_shift_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_speed', + 'unique_id': 'VINVINVIN-drive_state_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_speed', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] + EntityRegistryEntrySnapshot({ + '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.test_state_of_charge_at_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge at arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_energy_at_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_state_of_charge_at_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test State of charge at arrival', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_state_of_charge_at_arrival', + 'last_changed': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensors[sensor.test_time_to_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_time_to_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_minutes_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_time_to_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Time to arrival', + }), + 'context': , + 'entity_id': 'sensor.test_time_to_arrival', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:59:12+00:00', + }) +# --- +# name: test_sensors[sensor.test_time_to_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_time_to_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to full charge', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_minutes_to_full_charge', + 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_time_to_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Time to full charge', + }), + 'context': , + 'entity_id': 'sensor.test_time_to_full_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_left-entry] + EntityRegistryEntrySnapshot({ + '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.test_tire_pressure_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fl', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure front left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_front_left', + 'last_changed': , + 'last_updated': , + 'state': '43.1487288094417', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_right-entry] + EntityRegistryEntrySnapshot({ + '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.test_tire_pressure_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fr', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure front right', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_front_right', + 'last_changed': , + 'last_updated': , + 'state': '43.1487288094417', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_left-entry] + EntityRegistryEntrySnapshot({ + '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.test_tire_pressure_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rl', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure rear left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_rear_left', + 'last_changed': , + 'last_updated': , + 'state': '42.7861344496985', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_right-entry] + EntityRegistryEntrySnapshot({ + '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.test_tire_pressure_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rr', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure rear right', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_rear_right', + 'last_changed': , + 'last_updated': , + 'state': '42.7861344496985', + }) +# --- +# name: test_sensors[sensor.test_traffic_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_traffic_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Traffic delay', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_traffic_minutes_delay', + 'unique_id': 'VINVINVIN-drive_state_active_route_traffic_minutes_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_traffic_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Traffic delay', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_traffic_delay', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr new file mode 100644 index 00000000000..686542feacd --- /dev/null +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -0,0 +1,254 @@ +# serializer version: 1 +# name: test_switches[switch.test_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Charge', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_enable_request', + 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.test_defrost_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_defrost_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:snowflake', + 'original_name': 'Defrost mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_defrost_mode', + 'unique_id': 'VINVINVIN-climate_state_defrost_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_defrost_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost mode', + 'icon': 'mdi:snowflake', + }), + 'context': , + 'entity_id': 'switch.test_defrost_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_sentry_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_sentry_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:shield-car', + 'original_name': 'Sentry mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sentry_mode', + 'unique_id': 'VINVINVIN-vehicle_state_sentry_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_sentry_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + 'icon': 'mdi:shield-car', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:steering', + 'original_name': 'Steering wheel heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heater', + 'unique_id': 'VINVINVIN-climate_state_steering_wheel_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Steering wheel heater', + 'icon': 'mdi:steering', + }), + 'context': , + 'entity_id': 'switch.test_steering_wheel_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_valet_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_valet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:car-key', + 'original_name': 'Valet mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_valet_mode', + 'unique_id': 'VINVINVIN-vehicle_state_valet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_valet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + 'icon': 'mdi:car-key', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[turn_off] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[turn_on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr new file mode 100644 index 00000000000..b47cc78ef6e --- /dev/null +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_updates[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'update', + 'unique_id': 'VINVINVIN-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_updates[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/tessie/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.38.6', + 'latest_version': '2023.44.30.4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py index 7f1eb1805a2..ca53a60d493 100644 --- a/tests/components/tessie/test_binary_sensors.py +++ b/tests/components/tessie/test_binary_sensors.py @@ -1,33 +1,18 @@ """Test the Tessie binary sensor platform.""" +from syrupy import SnapshotAssertion -from homeassistant.components.tessie.binary_sensor import DESCRIPTIONS -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform - -OFFON = [STATE_OFF, STATE_ON] +from .common import assert_entities, setup_platform -async def test_binary_sensors(hass: HomeAssistant) -> None: +async def test_binary_sensors( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the binary sensor entities are correct.""" - assert len(hass.states.async_all("binary_sensor")) == 0 + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - 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"] + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index 153171c8b9f..674e7a32747 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -1,39 +1,40 @@ """Test the Tessie button platform.""" from unittest.mock import patch -import pytest +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import assert_entities, setup_platform -@pytest.mark.parametrize( - ("entity_id", "func"), - [ +async def test_buttons( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Tests that the button entities are correct.""" + + entry = await setup_platform(hass, [Platform.BUTTON]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + for entity_id, func in [ ("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() + ]: + with patch( + f"homeassistant.components.tessie.button.{func}", + ) as mock_press: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_press.assert_called_once() diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index 341e4714470..cbb6b7ad09e 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -2,53 +2,38 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion 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, + SERVICE_TURN_OFF, HVACMode, ) from homeassistant.components.tessie.const import TessieClimateKeeper -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ( - ERROR_UNKNOWN, - TEST_RESPONSE, - TEST_VEHICLE_STATE_ONLINE, - setup_platform, -) +from .common import ERROR_UNKNOWN, TEST_RESPONSE, assert_entities, setup_platform -async def test_climate(hass: HomeAssistant) -> None: +async def test_climate( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the climate entity is correct.""" - assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.CLIMATE]) - await setup_platform(hass) - - assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) 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( @@ -62,6 +47,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL # Test setting climate temp with patch( @@ -75,6 +62,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 # Test setting climate preset with patch( @@ -88,6 +77,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == TessieClimateKeeper.ON # Test setting climate off with patch( @@ -101,22 +92,24 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF async def test_errors(hass: HomeAssistant) -> None: - """Tests virtual key error is handled.""" + """Tests errors are handled.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" # Test setting climate on with unknown error with patch( - "homeassistant.components.tessie.climate.start_climate_preconditioning", + "homeassistant.components.tessie.climate.stop_climate", side_effect=ERROR_UNKNOWN, ) as mock_set, pytest.raises(HomeAssistantError) as error: await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index 7bc3efa24fc..e5bcf11efd1 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -132,9 +132,9 @@ async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> No async def test_reauth_errors( hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error ) -> None: - """Test reauth flows that failscript/.""" + """Test reauth flows that fail.""" - mock_entry = await setup_platform(hass) + mock_entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) mock_get_state_of_all_vehicles.side_effect = side_effect result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 65f91c6f33e..14bb6b7d203 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -1,8 +1,9 @@ """Test the Tessie sensor platform.""" from datetime import timedelta +from homeassistant.components.tessie import PLATFORMS from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -24,7 +25,7 @@ async def test_coordinator_online( ) -> None: """Tests that the coordinator handles online vehicles.""" - await setup_platform(hass) + await setup_platform(hass, PLATFORMS) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -36,7 +37,7 @@ async def test_coordinator_online( async def test_coordinator_asleep(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles asleep vehicles.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) mock_get_status.return_value = TEST_VEHICLE_STATUS_ASLEEP async_fire_time_changed(hass, utcnow() + WAIT) @@ -49,7 +50,7 @@ async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_status) -> """Tests that the coordinator handles client errors.""" mock_get_status.side_effect = ERROR_UNKNOWN - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -61,7 +62,7 @@ async def test_coordinator_auth(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles timeout errors.""" mock_get_status.side_effect = ERROR_AUTH - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -72,7 +73,7 @@ async def test_coordinator_connection(hass: HomeAssistant, mock_get_status) -> N """Tests that the coordinator handles connection errors.""" mock_get_status.side_effect = ERROR_CONNECTION - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_status.assert_called_once() diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 713108b962a..c86cce466e1 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -11,70 +11,72 @@ from homeassistant.components.cover import ( STATE_CLOSED, STATE_OPEN, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_RESPONSE_ERROR, + assert_entities, + setup_platform, +) -@pytest.mark.parametrize( - ("entity_id", "openfunc", "closefunc"), - [ +async def test_covers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the window cover entity is correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + for entity_id, openfunc, closefunc in [ ("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.""" + ]: + # 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 - 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 + # 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) + await setup_platform(hass, [Platform.COVER]) entity_id = "cover.test_charge_port_door" # Test setting cover open with unknown error @@ -91,13 +93,6 @@ async def test_errors(hass: HomeAssistant) -> None: 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", diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index d737b02b40e..08d96b7303e 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -1,36 +1,19 @@ """Test the Tessie device tracker platform.""" +from syrupy import SnapshotAssertion -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_STATE_OF_ALL_VEHICLES, setup_platform - -STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] +from .common import assert_entities, setup_platform -async def test_device_tracker(hass: HomeAssistant) -> None: +async def test_device_tracker( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the device tracker entities are correct.""" - assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) - 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"] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 93a1151a850..b1e4f24ac59 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -2,32 +2,33 @@ from unittest.mock import patch +import pytest +from syrupy import SnapshotAssertion + 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.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_locks(hass: HomeAssistant) -> None: +async def test_locks( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the lock entity is correct.""" - assert len(hass.states.async_all("lock")) == 0 + entry = await setup_platform(hass, [Platform.LOCK]) - await setup_platform(hass) - - assert len(hass.states.async_all("lock")) == 1 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) 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( @@ -36,8 +37,8 @@ async def test_locks(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_LOCKED mock_run.assert_called_once() + assert hass.states.get(entity_id).state == STATE_LOCKED with patch("homeassistant.components.tessie.lock.unlock") as mock_run: await hass.services.async_call( @@ -46,5 +47,27 @@ async def test_locks(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) + + mock_run.assert_called_once() + + # Test charge cable lock set value functions + entity_id = "lock.test_charge_cable_lock" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + + with patch( + "homeassistant.components.tessie.lock.open_unlock_charge_port" + ) 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 index f658fe28acd..c9e4c3b84bc 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -6,41 +6,39 @@ from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import ( - TEST_STATE_OF_ALL_VEHICLES, - TEST_VEHICLE_STATE_ONLINE, - setup_platform, -) +from .common import 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 +async def test_media_player( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Tests that the media player entity is correct when idle.""" - assert len(hass.states.async_all("media_player")) == 0 + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) - await setup_platform(hass) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - assert len(hass.states.async_all("media_player")) == 1 + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-paused") - state = hass.states.get("media_player.test_media_player") - assert state == snapshot + # The refresh fixture has music playing + freezer.tick(WAIT) + async_fire_time_changed(hass) - # 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 + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-playing" + ) diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 116c9a2657d..8a3d1a649c7 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -2,70 +2,61 @@ from unittest.mock import patch +from syrupy import SnapshotAssertion + 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.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_numbers(hass: HomeAssistant) -> None: +async def test_numbers( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the number entities are correct.""" - assert len(hass.states.async_all("number")) == 0 + entry = await setup_platform(hass, [Platform.NUMBER]) - 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" - ] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) # Test number set value functions + entity_id = "number.test_charge_current" 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}, + {ATTR_ENTITY_ID: [entity_id], "value": 16}, blocking=True, ) - assert hass.states.get("number.test_charge_current").state == "16.0" mock_set_charging_amps.assert_called_once() + assert hass.states.get(entity_id).state == "16.0" + entity_id = "number.test_charge_limit" 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}, + {ATTR_ENTITY_ID: [entity_id], "value": 80}, blocking=True, ) - assert hass.states.get("number.test_charge_limit").state == "80.0" mock_set_charge_limit.assert_called_once() + assert hass.states.get(entity_id).state == "80.0" + entity_id = "number.test_speed_limit" 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}, + {ATTR_ENTITY_ID: [entity_id], "value": 60}, blocking=True, ) - assert hass.states.get("number.test_speed_limit").state == "60.0" mock_set_speed_limit.assert_called_once() + assert hass.states.get(entity_id).state == "60.0" diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index 09afa9306a7..d22f8cccad7 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -2,30 +2,31 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion 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.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform +from .common import ERROR_UNKNOWN, TEST_RESPONSE, assert_entities, setup_platform -async def test_select(hass: HomeAssistant) -> None: +async def test_select( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the select entities are correct.""" - assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.SELECT]) - await setup_platform(hass) - - assert len(hass.states.async_all(SELECT_DOMAIN)) == 5 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "select.test_seat_heater_left" - assert hass.states.get(entity_id).state == STATE_OFF # Test changing select with patch( @@ -39,15 +40,15 @@ async def test_select(hass: HomeAssistant) -> None: 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 + assert mock_set.call_args[1]["seat"] == "front_left" + assert mock_set.call_args[1]["level"] == 1 + assert hass.states.get(entity_id) == snapshot(name=SERVICE_SELECT_OPTION) async def test_errors(hass: HomeAssistant) -> None: """Tests unknown error is handled.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.SELECT]) entity_id = "select.test_seat_heater_left" # Test setting cover open with unknown error diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index 0c719f66136..090f9df0ca5 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -1,24 +1,24 @@ """Test the Tessie sensor platform.""" -from homeassistant.components.tessie.sensor import DESCRIPTIONS -from homeassistant.const import STATE_UNKNOWN +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: """Tests that the sensor entities are correct.""" - assert len(hass.states.async_all("sensor")) == 0 + freezer.move_to("2024-01-01 00:00:00+00:00") - await setup_platform(hass) + entry = await setup_platform(hass, [Platform.SENSOR]) - 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 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 5bc24d12e5c..60f3fab490c 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -1,34 +1,30 @@ """Test the Tessie switch platform.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + 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.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_switches(hass: HomeAssistant) -> None: +async def test_switches( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the switche entities are correct.""" - assert len(hass.states.async_all("switch")) == 0 + entry = await setup_platform(hass, [Platform.SWITCH]) - 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"] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + entity_id = "switch.test_charge" with patch( "homeassistant.components.tessie.switch.start_charging", ) as mock_start_charging: @@ -36,10 +32,12 @@ async def test_switches(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ["switch.test_charge"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_start_charging.assert_called_once() + assert hass.states.get(entity_id) == snapshot(name=SERVICE_TURN_ON) + with patch( "homeassistant.components.tessie.switch.stop_charging", ) as mock_stop_charging: @@ -47,7 +45,9 @@ async def test_switches(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ["switch.test_charge"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_stop_charging.assert_called_once() + + assert hass.states.get(entity_id) == snapshot(name=SERVICE_TURN_OFF) diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 182acdf17ff..54e56c46b50 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -1,30 +1,30 @@ """Test the Tessie update platform.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + 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.const import ATTR_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import assert_entities, setup_platform -async def test_updates(hass: HomeAssistant) -> None: +async def test_updates( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that update entity is correct.""" - assert len(hass.states.async_all("update")) == 0 + entry = await setup_platform(hass, [Platform.UPDATE]) - await setup_platform(hass) - - assert len(hass.states.async_all("update")) == 1 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) 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" diff --git a/tests/components/thermobeacon/test_sensor.py b/tests/components/thermobeacon/test_sensor.py index 788426f605a..e8d77e3a487 100644 --- a/tests/components/thermobeacon/test_sensor.py +++ b/tests/components/thermobeacon/test_sensor.py @@ -24,7 +24,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, THERMOBEACON_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 4 + assert len(hass.states.async_all("sensor")) == 3 humid_sensor = hass.states.get("sensor.lanyard_mini_hygrometer_eeff_humidity") humid_sensor_attrs = humid_sensor.attributes diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index a7dd5fcf9c5..f66b608f6d3 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -23,3 +23,23 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) + +TP962R_SERVICE_INFO = BluetoothServiceInfo( + name="TP962R (0000)", + manufacturer_data={14081: b"\x00;\x0b7\x00"}, + service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], + address="aa:bb:cc:dd:ee:ff", + rssi=-52, + service_data={}, + source="local", +) + +TP962R_SERVICE_INFO_2 = BluetoothServiceInfo( + name="TP962R (0000)", + manufacturer_data={17152: b"\x00\x17\nC\x00", 14081: b"\x00;\x0b7\x00"}, + service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], + address="aa:bb:cc:dd:ee:ff", + rssi=-52, + service_data={}, + source="local", +) diff --git a/tests/components/thermopro/test_sensor.py b/tests/components/thermopro/test_sensor.py index 236a9d27df6..d754991f3d8 100644 --- a/tests/components/thermopro/test_sensor.py +++ b/tests/components/thermopro/test_sensor.py @@ -4,12 +4,94 @@ from homeassistant.components.thermopro.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import TP357_SERVICE_INFO +from . import TP357_SERVICE_INFO, TP962R_SERVICE_INFO, TP962R_SERVICE_INFO_2 from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info +async def test_sensors_tp962r(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, TP962R_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_2_internal_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "25" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 2 Internal Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_2_ambient_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "25" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 2 Ambient Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp962r_0000_probe_2_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "100" + assert ( + battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP962R (0000) Probe 2 Battery" + ) + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + inject_bluetooth_service_info(hass, TP962R_SERVICE_INFO_2) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 6 + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_1_internal_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "37" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 1 Internal Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_1_ambient_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "37" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 1 Ambient Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp962r_0000_probe_1_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "82.0" + assert ( + battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP962R (0000) Probe 1 Battery" + ) + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -24,14 +106,21 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 inject_bluetooth_service_info(hass, TP357_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.tp357_2142_temperature") - temp_sensor_attribtes = temp_sensor.attributes + temp_sensor_attributes = temp_sensor.attributes assert temp_sensor.state == "24.1" - assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Temperature" - assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" - assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Temperature" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp357_2142_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "100" + assert battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Battery" + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index 7ca6cbaf2ed..0b53c879c37 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -18,6 +18,9 @@ DATASET_3 = ( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) +TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") + +TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") ROUTER_DISCOVERY_GOOGLE_1 = { "type_": "_meshcop._udp.local.", diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index d8822a7d536..bcc16de4ed2 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -1,14 +1,25 @@ """Test the thread dataset store.""" +import asyncio from typing import Any +from unittest.mock import ANY, AsyncMock, patch import pytest from python_otbr_api.tlv_parser import TLVError +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant.components.thread import dataset_store +from homeassistant.components.thread import dataset_store, discovery from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import DATASET_1, DATASET_2, DATASET_3 +from . import ( + DATASET_1, + DATASET_2, + DATASET_3, + ROUTER_DISCOVERY_GOOGLE_1, + ROUTER_DISCOVERY_HASS, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, + TEST_BORDER_AGENT_ID, +) from tests.common import flush_store @@ -107,6 +118,7 @@ async def test_delete_preferred_dataset(hass: HomeAssistant) -> None: store = await dataset_store.async_get_store(hass) dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id with pytest.raises(HomeAssistantError, match="attempt to remove preferred dataset"): store.async_delete(dataset_id) @@ -130,6 +142,10 @@ async def test_get_preferred_dataset(hass: HomeAssistant) -> None: await dataset_store.async_add_dataset(hass, "source", DATASET_1) + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id + assert (await dataset_store.async_get_preferred_dataset(hass)) == DATASET_1 @@ -254,8 +270,10 @@ async def test_load_datasets(hass: HomeAssistant) -> None: store1 = await dataset_store.async_get_store(hass) for dataset in datasets: - store1.async_add(dataset["source"], dataset["tlv"], None) + store1.async_add(dataset["source"], dataset["tlv"], None, None) assert len(store1.datasets) == 3 + dataset_id = list(store1.datasets.values())[0].id + store1.preferred_dataset = dataset_id for dataset in store1.datasets.values(): if dataset.source == "Google": @@ -304,6 +322,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", + "preferred_extended_address": "AEEB2F594B570BBF", "source": "source_1", "tlv": DATASET_1, }, @@ -311,6 +330,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", "preferred_border_agent_id": None, + "preferred_extended_address": "AEEB2F594B570BBF", "source": "source_2", "tlv": DATASET_2, }, @@ -318,6 +338,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "source_3", "tlv": DATASET_3, }, @@ -539,39 +560,414 @@ async def test_migrate_set_default_border_agent_id( store = await dataset_store.async_get_store(hass) assert store.datasets[store._preferred_dataset].preferred_border_agent_id is None + assert store.datasets[store._preferred_dataset].preferred_extended_address is None async def test_set_preferred_border_agent_id(hass: HomeAssistant) -> None: """Test set the preferred border agent ID of a dataset.""" assert await dataset_store.async_get_preferred_dataset(hass) is None + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="blah" + ) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 0 + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="bleh" + ) + assert len(store.datasets) == 0 + + await dataset_store.async_add_dataset(hass, "source", DATASET_2) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + with pytest.raises(HomeAssistantError): + await store.async_set_preferred_border_agent(dataset_id, "blah", None) + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert len(store.datasets) == 2 + assert list(store.datasets.values())[1].preferred_border_agent_id is None + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[1].preferred_border_agent_id is None + + +async def test_set_preferred_border_agent_id_and_extended_address( + hass: HomeAssistant, +) -> None: + """Test set the preferred border agent ID and extended address of a dataset.""" + assert await dataset_store.async_get_preferred_dataset(hass) is None + await dataset_store.async_add_dataset( - hass, "source", DATASET_3, preferred_border_agent_id="blah" + hass, + "source", + DATASET_3, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[0].preferred_extended_address == "bleh" await dataset_store.async_add_dataset( - hass, "source", DATASET_3, preferred_border_agent_id="bleh" + hass, + "source", + DATASET_3, + preferred_border_agent_id="bleh", + preferred_extended_address="bleh", ) assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[0].preferred_extended_address == "bleh" await dataset_store.async_add_dataset(hass, "source", DATASET_2) assert len(store.datasets) == 2 assert list(store.datasets.values())[1].preferred_border_agent_id is None + assert list(store.datasets.values())[1].preferred_extended_address is None await dataset_store.async_add_dataset( - hass, "source", DATASET_2, preferred_border_agent_id="blah" + hass, + "source", + DATASET_2, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[1].preferred_extended_address == "bleh" await dataset_store.async_add_dataset(hass, "source", DATASET_1) assert len(store.datasets) == 3 assert list(store.datasets.values())[2].preferred_border_agent_id is None + assert list(store.datasets.values())[2].preferred_extended_address is None await dataset_store.async_add_dataset( - hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" + hass, + "source", + DATASET_1_LARGER_TIMESTAMP, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) - assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[2].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[2].preferred_extended_address == "bleh" + + +async def test_set_preferred_extended_address(hass: HomeAssistant) -> None: + """Test set the preferred extended address of a dataset.""" + assert await dataset_store.async_get_preferred_dataset(hass) is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_extended_address="blah" + ) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_extended_address="bleh" + ) + assert list(store.datasets.values())[0].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_2) + assert len(store.datasets) == 2 + assert list(store.datasets.values())[1].preferred_extended_address is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_extended_address="blah" + ) + assert list(store.datasets.values())[1].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert len(store.datasets) == 3 + assert list(store.datasets.values())[2].preferred_extended_address is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_extended_address="blah" + ) + assert list(store.datasets.values())[2].preferred_extended_address == "blah" + + +async def test_automatically_set_preferred_dataset( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset.""" + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Wait for discovery of other routers to time out and discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) == DATASET_1 + + +async def test_automatically_set_preferred_dataset_own_and_other_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case both our own and another router are found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Discover another router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_GOOGLE_1 + ) + listener.add_service( + None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"] + ) + + # Wait for discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None + + +async def test_automatically_set_preferred_dataset_other_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case another router is found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover another router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_GOOGLE_1 + ) + listener.add_service( + None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"] + ) + + # Wait for discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None + + +async def test_automatically_set_preferred_dataset_no_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case no routers are found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Wait for discovery of other routers to time out and discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 75e1b313132..b277dcafcf4 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -86,6 +86,17 @@ async def test_delete_dataset( assert msg["success"] datasets = msg["result"]["datasets"] + # Set the first dataset as preferred + await client.send_json_auto_id( + { + "type": "thread/set_preferred_dataset", + "dataset_id": datasets[0]["dataset_id"], + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + # Try deleting the preferred dataset await client.send_json_auto_id( {"type": "thread/delete_dataset", "dataset_id": datasets[0]["dataset_id"]} @@ -139,6 +150,9 @@ async def test_list_get_dataset( await dataset_store.async_add_dataset(hass, dataset["source"], dataset["tlv"]) store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id + for dataset in store.datasets.values(): if dataset.source == "Google": dataset_1 = dataset @@ -161,6 +175,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": True, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "Google", }, { @@ -172,6 +187,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": False, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "Multipan", }, { @@ -183,6 +199,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": False, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "🎅", }, ] @@ -203,7 +220,7 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} -async def test_set_preferred_border_agent_id( +async def test_set_preferred_border_agent( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test setting the preferred border agent ID.""" @@ -225,12 +242,14 @@ async def test_set_preferred_border_agent_id( datasets = msg["result"]["datasets"] dataset_id = datasets[0]["dataset_id"] assert datasets[0]["preferred_border_agent_id"] is None + assert datasets[0]["preferred_extended_address"] is None await client.send_json_auto_id( { - "type": "thread/set_preferred_border_agent_id", + "type": "thread/set_preferred_border_agent", "dataset_id": dataset_id, "border_agent_id": "blah", + "extended_address": "bleh", } ) msg = await client.receive_json() @@ -242,6 +261,7 @@ async def test_set_preferred_border_agent_id( assert msg["success"] datasets = msg["result"]["datasets"] assert datasets[0]["preferred_border_agent_id"] == "blah" + assert datasets[0]["preferred_extended_address"] == "bleh" async def test_set_preferred_dataset( diff --git a/tests/components/time_date/__init__.py b/tests/components/time_date/__init__.py index 22734c19bbb..9817271a8d9 100644 --- a/tests/components/time_date/__init__.py +++ b/tests/components/time_date/__init__.py @@ -1 +1,30 @@ """Tests for the time_date component.""" + +from homeassistant.components.time_date.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DISPLAY_OPTIONS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def load_int( + hass: HomeAssistant, display_option: str | None = None +) -> MockConfigEntry: + """Set up the Time & Date integration in Home Assistant.""" + if display_option is None: + display_option = "time" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={CONF_DISPLAY_OPTIONS: display_option}, + entry_id=f"1234567890_{display_option}", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/time_date/conftest.py b/tests/components/time_date/conftest.py new file mode 100644 index 00000000000..af732f978b4 --- /dev/null +++ b/tests/components/time_date/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Time & Date integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.time_date.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py new file mode 100644 index 00000000000..228a34b65b4 --- /dev/null +++ b/tests/components/time_date/test_config_flow.py @@ -0,0 +1,138 @@ +"""Test the Time & Date config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": "time"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_does_not_allow_beat( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with pytest.raises(vol.Invalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["beat"]}, + ) + + +async def test_single_instance(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: "time"} + ) + 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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": "time"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_timezone_not_set(hass: HomeAssistant) -> None: + """Test time zone not set.""" + hass.config.time_zone = None + + 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"], + {"display_options": "time"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timezone_not_exist"} + + +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + freezer.move_to("2024-01-02 20:14:11.672") + + 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" + assert result["errors"] is None + assert result["preview"] == "time_date" + + await client.send_json_auto_id( + { + "type": "time_date/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"display_options": "time"}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + } + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:15", + } + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/time_date/test_init.py b/tests/components/time_date/test_init.py new file mode 100644 index 00000000000..cd7c5044201 --- /dev/null +++ b/tests/components/time_date/test_init.py @@ -0,0 +1,18 @@ +"""The tests for the Time & Date component.""" + +from homeassistant.core import HomeAssistant + +from . import load_int + + +async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: + """Test setting up and removing a config entry.""" + entry = await load_int(hass) + + state = hass.states.get("sensor.time") + assert state is not None + + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.time") is None diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index f9ef8a7cfe9..d7e87b3a471 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,174 +1,327 @@ """The tests for time_date sensor platform.""" -from freezegun.api import FrozenDateTimeFactory +from unittest.mock import ANY, Mock, patch -import homeassistant.components.time_date.sensor as time_date +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES 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 . import load_int -async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test timing intervals of sensors.""" - device = time_date.TimeDateSensor(hass, "time") - now = dt_util.utc_from_timestamp(45.5) - freezer.move_to(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") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") - - device = time_date.TimeDateSensor(hass, "date_time") - now = dt_util.utc_from_timestamp(1495068899) - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time == dt_util.utc_from_timestamp(1495068900) - - now = dt_util.utcnow() - device = time_date.TimeDateSensor(hass, "time_date") - next_time = device.get_next_interval() - assert next_time > now +from tests.common import async_fire_time_changed -async def test_states(hass: HomeAssistant) -> None: +@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") + freezer.move_to(start_time) + + await load_int(hass, display_option) + + mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) + + +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) - device = time_date.TimeDateSensor(hass, "date") - device._update_internal_state(now) - assert device.state == "2017-05-18" + for option in OPTION_TYPES: + await load_int(hass, option) - 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 == "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.date") + assert state.state == "2017-05-18" - 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" - device._update_internal_state(dt_util.utc_from_timestamp(1602952963.2)) - assert device.state == "@738" + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-18, 00:54" - 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.date_time_utc") + assert state.state == "2017-05-18, 00:54" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@079" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-18T00: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 == "16: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, 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) -> None: +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" + for option in OPTION_TYPES: + await load_int(hass, option) - 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" + + 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" + + 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.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( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + mock_track_interval: Mock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, + start_time, + tracked_time, ) -> None: - """Test date sensor behavior in a timezone besides UTC.""" - hass.config.set_time_zone("America/New_York") + """Test timing intervals of sensors in timezone other than UTC.""" + hass.config.set_time_zone(time_zone) + freezer.move_to(start_time) - device = time_date.TimeDateSensor(hass, "date") - now = dt_util.utc_from_timestamp(50000) - freezer.move_to(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 + await load_int(hass, "date") - 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") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") + mock_track_interval.assert_called_once() + next_time = mock_track_interval.mock_calls[0][1][2] - # Entering DST - hass.config.set_time_zone("Europe/Prague") - - now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - freezer.move_to(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") - freezer.move_to(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") - freezer.move_to(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") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") - - -async def test_timezone_intervals_empty_parameter( - hass: HomeAssistant, freezer: FrozenDateTimeFactory -) -> None: - """Test get_interval() without parameters.""" - freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) - 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") + 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" + for option in OPTION_TYPES: + await load_int(hass, option) + + 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, + } + } + + assert 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/timer/test_init.py b/tests/components/timer/test_init.py index 6b6929e88ec..92baa013f14 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -147,7 +147,7 @@ async def test_config_options(hass: HomeAssistant) -> None: async def test_methods_and_events(hass: HomeAssistant) -> None: """Test methods and events.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -393,7 +393,7 @@ async def test_start_service(hass: HomeAssistant) -> None: async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: """Test for a timer to end.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 20}}}) @@ -460,7 +460,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -569,7 +569,7 @@ async def test_config_reload( async def test_timer_restarted_event(hass: HomeAssistant) -> None: """Ensure restarted event is called after starting a paused or running timer.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -636,7 +636,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None: async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None: """Ensure timer's state changes when it restarted.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 5aa1e2af9de..a227ec858e4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -402,8 +402,52 @@ async def test_update_todo_item_status( "status": "needs_action", }, ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + # Create a mock task with a string value in the Due object and verify it + # gets preserved when verifying the kwargs to update below + due=Due(date="2024-01-01", is_recurring=True, string="every day"), + ) + ], + {"due_date": "2024-02-01"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + due=Due(date="2024-02-01", is_recurring=True, string="every day"), + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "6-pack", + "due_date": "2024-02-01", + "due_string": "every day", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + "due": "2024-02-01", + }, + ), + ], + ids=[ + "rename", + "due_date", + "due_datetime", + "description", + "clear_description", + "due_date_with_recurrence", ], - ids=["rename", "due_date", "due_datetime", "description", "clear_description"], ) async def test_update_todo_items( hass: HomeAssistant, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 9006a058c57..30e59014bbf 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -3,6 +3,10 @@ from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, SmartBulb, SmartDevice, SmartDimmer, @@ -11,9 +15,15 @@ from kasa import ( SmartStrip, ) from kasa.exceptions import SmartDeviceException -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.protocol import BaseProtocol -from homeassistant.components.tplink import CONF_HOST +from homeassistant.components.tplink import ( + CONF_ALIAS, + CONF_DEVICE_CONFIG, + CONF_HOST, + CONF_MODEL, + Credentials, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.core import HomeAssistant @@ -22,23 +32,79 @@ from tests.common import MockConfigEntry MODULE = "homeassistant.components.tplink" MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" ALIAS = "My Bulb" MODEL = "HS100" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +MAC_ADDRESS2 = "11:22:33:44:55:66" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +CREDENTIALS_HASH_LEGACY = "" +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict( + credentials_hash=CREDENTIALS_HASH_LEGACY, exclude_credentials=True +) +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv==" +DEVICE_CONFIG_AUTH = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_AUTH2 = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) +DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) + +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, +} + +CREATE_ENTRY_DATA_AUTH = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, +} +CREATE_ENTRY_DATA_AUTH2 = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH2, +} -def _mock_protocol() -> TPLinkSmartHomeProtocol: - protocol = MagicMock(auto_spec=TPLinkSmartHomeProtocol) +def _mock_protocol() -> BaseProtocol: + protocol = MagicMock(auto_spec=BaseProtocol) protocol.close = AsyncMock() return protocol -def _mocked_bulb() -> SmartBulb: +def _mocked_bulb( + device_config=DEVICE_CONFIG_LEGACY, + credentials_hash=CREDENTIALS_HASH_LEGACY, + mac=MAC_ADDRESS, + alias=ALIAS, +) -> SmartBulb: bulb = MagicMock(auto_spec=SmartBulb, name="Mocked bulb") bulb.update = AsyncMock() - bulb.mac = MAC_ADDRESS - bulb.alias = ALIAS + bulb.mac = mac + bulb.alias = alias bulb.model = MODEL bulb.host = IP_ADDRESS bulb.brightness = 50 @@ -52,7 +118,7 @@ def _mocked_bulb() -> SmartBulb: bulb.effect = None bulb.effect_list = None bulb.hsv = (10, 30, 5) - bulb.device_id = MAC_ADDRESS + bulb.device_id = mac bulb.valid_temperature_range.min = 4000 bulb.valid_temperature_range.max = 9000 bulb.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} @@ -62,6 +128,8 @@ def _mocked_bulb() -> SmartBulb: bulb.set_hsv = AsyncMock() bulb.set_color_temp = AsyncMock() bulb.protocol = _mock_protocol() + bulb.config = device_config + bulb.credentials_hash = credentials_hash return bulb @@ -103,6 +171,8 @@ def _mocked_smart_light_strip() -> SmartLightStrip: strip.set_effect = AsyncMock() strip.set_custom_effect = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY return strip @@ -134,6 +204,8 @@ def _mocked_dimmer() -> SmartDimmer: dimmer.set_color_temp = AsyncMock() dimmer.set_led = AsyncMock() dimmer.protocol = _mock_protocol() + dimmer.config = DEVICE_CONFIG_LEGACY + dimmer.credentials_hash = CREDENTIALS_HASH_LEGACY return dimmer @@ -155,6 +227,8 @@ def _mocked_plug() -> SmartPlug: plug.turn_on = AsyncMock() plug.set_led = AsyncMock() plug.protocol = _mock_protocol() + plug.config = DEVICE_CONFIG_LEGACY + plug.credentials_hash = CREDENTIALS_HASH_LEGACY return plug @@ -176,6 +250,8 @@ def _mocked_strip() -> SmartStrip: strip.turn_on = AsyncMock() strip.set_led = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY plug0 = _mocked_plug() plug0.alias = "Plug0" plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" @@ -212,6 +288,15 @@ def _patch_single_discovery(device=None, no_device=False): ) +def _patch_connect(device=None, no_device=False): + async def _connect(*args, **kwargs): + if no_device: + raise SmartDeviceException + return device if device else _mocked_bulb() + + return patch("homeassistant.components.tplink.SmartDevice.connect", new=_connect) + + async def initialize_config_entry_for_device( hass: HomeAssistant, dev: SmartDevice ) -> MockConfigEntry: @@ -225,7 +310,9 @@ async def initialize_config_entry_for_device( ) config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_single_discovery( + device=dev + ), _patch_connect(device=dev): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 20ce09b9ec8..7e7e6961b91 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,18 +1,75 @@ """tplink conftest.""" +from collections.abc import Generator +import copy +from unittest.mock import DEFAULT, AsyncMock, patch + import pytest -from . import _patch_discovery +from homeassistant.components.tplink import DOMAIN +from homeassistant.core import HomeAssistant -from tests.common import mock_device_registry, mock_registry +from . import ( + CREATE_ENTRY_DATA_LEGACY, + CREDENTIALS_HASH_AUTH, + DEVICE_CONFIG_AUTH, + IP_ADDRESS, + IP_ADDRESS2, + MAC_ADDRESS, + MAC_ADDRESS2, + _mocked_bulb, +) + +from tests.common import MockConfigEntry, mock_device_registry, mock_registry @pytest.fixture def mock_discovery(): """Mock python-kasa discovery.""" - with _patch_discovery() as mock_discover: - mock_discover.return_value = {} - yield mock_discover + with patch.multiple( + "homeassistant.components.tplink.Discover", + discover=DEFAULT, + discover_single=DEFAULT, + ) as mock_discovery: + device = _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + devices = { + "127.0.0.1": _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + } + mock_discovery["discover"].return_value = devices + mock_discovery["discover_single"].return_value = device + mock_discovery["mock_device"] = device + yield mock_discovery + + +@pytest.fixture +def mock_connect(): + """Mock python-kasa connect.""" + with patch("homeassistant.components.tplink.SmartDevice.connect") as mock_connect: + devices = { + IP_ADDRESS: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, credentials_hash=CREDENTIALS_HASH_AUTH + ), + IP_ADDRESS2: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, + credentials_hash=CREDENTIALS_HASH_AUTH, + mac=MAC_ADDRESS2, + ), + } + + def get_device(config): + nonlocal devices + return devices[config.host] + + mock_connect.side_effect = get_device + yield {"connect": mock_connect, "mock_devices": devices} @pytest.fixture(name="device_reg") @@ -30,3 +87,55 @@ def entity_reg_fixture(hass): @pytest.fixture(autouse=True) def tplink_mock_get_source_ip(mock_get_source_ip): """Mock network util's async_get_source_ip.""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + ) as mock_setup_entry: + mock_setup_entry["async_setup"].return_value = True + mock_setup_entry["async_setup_entry"].return_value = True + yield mock_setup_entry + + +@pytest.fixture +def mock_init() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + "homeassistant.components.tplink", + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + async_unload_entry=DEFAULT, + ) as mock_init: + mock_init["async_setup"].return_value = True + mock_init["async_setup_entry"].return_value = True + mock_init["async_unload_entry"].return_value = True + yield mock_init + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_LEGACY}, + unique_id=MAC_ADDRESS, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init, +) -> 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 diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 65be41a5655..f5b0ba6c41f 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1,21 +1,44 @@ """Test the tplink config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from kasa import TimeoutException import pytest from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.tplink import DOMAIN -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.components.tplink import ( + DOMAIN, + AuthenticationException, + Credentials, + DeviceConfig, + SmartDeviceException, +) +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( ALIAS, + CREATE_ENTRY_DATA_AUTH, + CREATE_ENTRY_DATA_AUTH2, + CREATE_ENTRY_DATA_LEGACY, DEFAULT_ENTRY_TITLE, + DEVICE_CONFIG_DICT_AUTH, + DEVICE_CONFIG_DICT_LEGACY, IP_ADDRESS, MAC_ADDRESS, + MAC_ADDRESS2, MODULE, + _mocked_bulb, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -25,7 +48,7 @@ from tests.common import MockConfigEntry async def test_discovery(hass: HomeAssistant) -> None: """Test setting up discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,7 +77,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -67,7 +90,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result3["type"] == "create_entry" assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == {CONF_HOST: IP_ADDRESS} + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -75,18 +98,219 @@ async def test_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "no_devices_found" +async def test_discovery_auth( + hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init +) -> None: + """Test authenticated discovery.""" + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_ENTRY_TITLE + assert result2["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_discovery_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test handling of discovery authentication errors.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_confirm" + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new invalid credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + + mock_connect["connect"].side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_auth_confirm" + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: """Test setting up discovery.""" config_entry = MockConfigEntry( @@ -94,22 +318,24 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No ) config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(no_device=True): + with _patch_discovery(), _patch_single_discovery(no_device=True), _patch_connect( + no_device=True + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] @@ -118,29 +344,27 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: MAC_ADDRESS} ) - assert result3["type"] == "create_entry" + assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -149,15 +373,15 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -167,11 +391,11 @@ async def test_discovery_no_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(): + with _patch_discovery(no_device=True), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -180,46 +404,48 @@ async def test_manual(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] # Cannot connect (timeout) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} # Success - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result4["type"] == "create_entry" + assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == DEFAULT_ENTRY_TITLE - assert result4["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result4["data"] == CREATE_ENTRY_DATA_LEGACY # Duplicate result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -228,11 +454,13 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(no_device=True), _patch_single_discovery(), patch( + with _patch_discovery( + no_device=True + ), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( @@ -240,26 +468,133 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == CREATE_ENTRY_DATA_LEGACY + + +async def test_manual_auth( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_manual_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test manually setup auth errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user_auth_confirm" + assert result3["errors"] == {error_placement: errors_msg} + + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + await hass.async_block_till_done() async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: """Test we get the form with discovery and abort for dhcp source when we get both.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -268,10 +603,10 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_in_progress" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -280,10 +615,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result3["type"] == FlowResultType.ABORT + assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_in_progress" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -305,7 +642,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -314,16 +656,16 @@ async def test_discovered_by_dhcp_or_discovery( ) -> None: """Test we can setup when discovered from dhcp or discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_async_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -331,10 +673,8 @@ async def test_discovered_by_dhcp_or_discovery( result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == CREATE_ENTRY_DATA_LEGACY assert mock_async_setup.called assert mock_async_setup_entry.called @@ -348,7 +688,12 @@ async def test_discovered_by_dhcp_or_discovery( ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -357,10 +702,401 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_discovery_with_ip_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = SmartDeviceException() + 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 == config_entries.ConfigEntryState.SETUP_RETRY + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1" + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + + config = DeviceConfig.from_dict(DEVICE_CONFIG_DICT_AUTH) + + mock_connect["connect"].reset_mock(side_effect=True) + bulb = _mocked_bulb( + device_config=config, + mac=mock_config_entry.unique_id, + ) + mock_connect["connect"].return_value = bulb + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED + # Check that init set the new host correctly before calling connect + assert config.host == "127.0.0.1" + config.host = "127.0.0.2" + mock_connect["connect"].assert_awaited_once_with(config=config) + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state == config_entries.ConfigEntryState.LOADED + 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"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + + +async def test_reauth_update_from_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException + 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 == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + +async def test_reauth_update_from_discovery_with_ip_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException() + 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 == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + + +async def test_reauth_no_update_if_config_and_ip_the_same( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth discovery does not update when the host and config are the same.""" + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.data = { + **mock_config_entry.data, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + } + 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 config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + errors_msg, + error_placement, +) -> None: + """Test reauth errors.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state is config_entries.ConfigEntryState.LOADED + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_discovery["mock_device"].update.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + mock_discovery["discover_single"].reset_mock() + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error_type", "expected_flow"), + [ + (AuthenticationException, FlowResultType.FORM), + (SmartDeviceException, FlowResultType.ABORT), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_pick_device_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + expected_flow, +) -> None: + """Test errors on pick_device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + assert result3["type"] == expected_flow + + if expected_flow != FlowResultType.ABORT: + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] == FlowResultType.CREATE_ENTRY + + +async def test_discovery_timeout_connect( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test discovery tries legacy connect on timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_discovery["discover_single"].side_effect = TimeoutException + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + assert mock_connect["connect"].call_count == 0 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert mock_connect["connect"].call_count == 1 + + +async def test_reauth_update_other_flows( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + # mock_init, +) -> None: + """Test reauth updates other reauth flows.""" + mock_config_entry2 = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH2}, + unique_id=MAC_ADDRESS2, + ) + default_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry2.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + mock_connect["connect"].side_effect = default_side_effect + + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 2 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index c40560d2a89..7bee7823013 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,25 +1,39 @@ """Tests for the TP-Link component.""" from __future__ import annotations +import copy from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from kasa.exceptions import AuthenticationException import pytest from homeassistant import setup from homeassistant.components import tplink -from homeassistant.components.tplink.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STARTED, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + CREATE_ENTRY_DATA_AUTH, + DEVICE_CONFIG_AUTH, IP_ADDRESS, MAC_ADDRESS, _mocked_dimmer, + _mocked_plug, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -57,7 +71,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.LOADED @@ -72,7 +86,9 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -102,7 +118,9 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( original_name="Rollout dimmer", ) - with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): + with _patch_discovery(device=dimmer), _patch_single_discovery( + device=dimmer + ), _patch_connect(device=dimmer): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -126,7 +144,7 @@ async def test_config_entry_wrong_mac_Address( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_mac ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -135,3 +153,139 @@ async def test_config_entry_wrong_mac_Address( "Unexpected device found at 127.0.0.1; expected aa:bb:cc:dd:ee:f0, found aa:bb:cc:dd:ee:ff" in caplog.text ) + + +async def test_config_entry_device_config( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded with DeviceConfig.""" + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + 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 + + +async def test_config_entry_with_stored_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded when stored credentials are set.""" + stored_credentials = tplink.Credentials("fake_username1", "fake_password1") + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + auth = { + CONF_USERNAME: stored_credentials.username, + CONF_PASSWORD: stored_credentials.password, + } + + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth + 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 + config = DEVICE_CONFIG_AUTH + assert config.credentials != stored_credentials + config.credentials = stored_credentials + mock_connect["connect"].assert_called_once_with(config=config) + + +async def test_config_entry_device_config_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + caplog, +) -> None: + """Test that an invalid device config logs an error and loads the config entry.""" + entry_data = copy.deepcopy(CREATE_ENTRY_DATA_AUTH) + entry_data[CONF_DEVICE_CONFIG] = {"foo": "bar"} + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**entry_data}, + unique_id=MAC_ADDRESS, + ) + 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 ( + f"Invalid connection type dict for {IP_ADDRESS}: {entry_data.get(CONF_DEVICE_CONFIG)}" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("error_type", "entry_state", "reauth_flows"), + [ + (tplink.AuthenticationException, ConfigEntryState.SETUP_ERROR, True), + (tplink.SmartDeviceException, ConfigEntryState.SETUP_RETRY, False), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_config_entry_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + entry_state, + reauth_flows, +) -> None: + """Test that device exceptions are handled correctly during init.""" + mock_connect["connect"].side_effect = error_type + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + 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 entry_state + assert ( + any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + == reauth_flows + ) + + +async def test_plug_auth_fails(hass: HomeAssistant) -> None: + """Test a smart plug auth failure.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) + config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + plug.update = AsyncMock(side_effect=AuthenticationException) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + assert ( + len( + hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": SOURCE_REAUTH} + ) + ) + == 1 + ) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index ada454e0192..bd8a380daa1 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -33,6 +33,7 @@ from . import ( MAC_ADDRESS, _mocked_bulb, _mocked_smart_light_strip, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -48,7 +49,7 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) bulb = _mocked_bulb() bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +70,7 @@ async def test_color_light( ) already_migrated_config_entry.add_to_hass(hass) bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -85,7 +86,7 @@ async def test_color_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp", "hs"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] assert attributes[ATTR_MIN_MIREDS] == 111 assert attributes[ATTR_MAX_MIREDS] == 250 assert attributes[ATTR_HS_COLOR] == (10, 30) @@ -151,7 +152,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.is_variable_color_temp = False type(bulb).color_temp = PropertyMock(side_effect=Exception) - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -162,7 +163,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "hs"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] assert attributes[ATTR_HS_COLOR] == (10, 30) assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) @@ -212,7 +213,7 @@ async def test_color_temp_light( bulb.color_temp = 4000 bulb.is_variable_color_temp = True - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -224,13 +225,9 @@ async def test_color_temp_light( assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" if bulb.is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - "brightness", - "color_temp", - "hs", - ] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] assert attributes[ATTR_MIN_MIREDS] == 111 assert attributes[ATTR_MAX_MIREDS] == 250 assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 @@ -295,7 +292,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: bulb.is_color = False bulb.is_variable_color_temp = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -340,7 +337,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: bulb.is_variable_color_temp = False bulb.is_dimmable = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -375,7 +372,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: bulb.is_dimmable = False bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -397,7 +394,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: bulb.is_dimmer = True bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -421,7 +418,9 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_single_discovery( + device=strip + ), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -501,7 +500,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -664,7 +663,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> "name": "Custom", "enable": 0, } - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -691,7 +690,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 5413e036d96..b67ed031df3 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -8,13 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import ( - MAC_ADDRESS, - _mocked_bulb, - _mocked_plug, - _patch_discovery, - _patch_single_discovery, -) +from . import MAC_ADDRESS, _mocked_bulb, _mocked_plug, _patch_connect, _patch_discovery from tests.common import MockConfigEntry @@ -35,7 +29,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: current=5, ) bulb.emeter_today = 5000.0036 - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -75,7 +69,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: current=5.035, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -103,7 +97,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_temp = None bulb.has_emeter = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -139,7 +133,7 @@ async def test_sensor_unique_id(hass: HomeAssistant) -> None: current=5, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 05286e5ff48..372651ea250 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -20,8 +20,8 @@ from . import ( _mocked_dimmer, _mocked_plug, _mocked_strip, + _patch_connect, _patch_discovery, - _patch_single_discovery, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -34,7 +34,7 @@ async def test_plug(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_connect(device=dev): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -100,7 +100,7 @@ async def test_plug_unique_id(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -116,7 +116,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -138,7 +138,7 @@ async def test_strip(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -186,7 +186,7 @@ async def test_strip_unique_ids(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink_omada/test_config_flow.py b/tests/components/tplink_omada/test_config_flow.py index cf3fddf5943..1a9635d44cb 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -401,7 +401,7 @@ async def test_create_omada_client_with_ip_creates_clientsession( hass, { "host": "10.10.10.10", - "verify_ssl": True, # Verify is meaningless for IP + "verify_ssl": True, "username": "test-username", "password": "test-password", }, @@ -412,5 +412,5 @@ async def test_create_omada_client_with_ip_creates_clientsession( "https://10.10.10.10", "test-username", "test-password", "ws" ) mock_create_clientsession.assert_called_once_with( - hass, cookie_jar=mock_jar.return_value + hass, cookie_jar=mock_jar.return_value, verify_ssl=True ) diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py deleted file mode 100644 index ed6cc3f629b..00000000000 --- a/tests/components/traccar/test_device_tracker.py +++ /dev/null @@ -1,78 +0,0 @@ -"""The tests for the Traccar device tracker platform.""" -from unittest.mock import AsyncMock, patch - -from pytraccar import ReportsEventeModel - -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.components.traccar.device_tracker import ( - PLATFORM_SCHEMA as TRACCAR_PLATFORM_SCHEMA, -) -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_PASSWORD, - CONF_PLATFORM, - CONF_USERNAME, -) -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 - - -async def test_import_events_catch_all(hass: HomeAssistant) -> None: - """Test importing all events and firing them in HA using their event types.""" - conf_dict = { - DOMAIN: TRACCAR_PLATFORM_SCHEMA( - { - CONF_PLATFORM: "traccar", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_EVENT: ["all_events"], - } - ) - } - - device = {"id": 1, "name": "abc123"} - api_mock = AsyncMock() - api_mock.devices = [device] - api_mock.get_reports_events.return_value = [ - ReportsEventeModel( - **{ - "id": 1, - "positionId": 1, - "geofenceId": 1, - "maintenanceId": 1, - "deviceId": device["id"], - "type": "ignitionOn", - "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "attributes": {}, - } - ), - ReportsEventeModel( - **{ - "id": 2, - "positionId": 2, - "geofenceId": 1, - "maintenanceId": 1, - "deviceId": device["id"], - "type": "ignitionOff", - "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "attributes": {}, - } - ), - ] - - events_ignition_on = async_capture_events(hass, "traccar_ignition_on") - events_ignition_off = async_capture_events(hass, "traccar_ignition_off") - - with patch( - "homeassistant.components.traccar.device_tracker.ApiClient", - return_value=api_mock, - ): - assert await async_setup_component(hass, DOMAIN, conf_dict) - - assert len(events_ignition_on) == 1 - assert len(events_ignition_off) == 1 diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 1ac7adfb549..b85701f9c72 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -25,7 +25,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture(name="client") -async def traccar_client(event_loop, hass, hass_client_no_auth): +async def traccar_client(hass, hass_client_no_auth): """Mock client for Traccar (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -37,7 +37,7 @@ async def traccar_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/components/traccar_server/__init__.py b/tests/components/traccar_server/__init__.py new file mode 100644 index 00000000000..7b7a59d3b61 --- /dev/null +++ b/tests/components/traccar_server/__init__.py @@ -0,0 +1 @@ +"""Tests for the Traccar Server integration.""" diff --git a/tests/components/traccar_server/conftest.py b/tests/components/traccar_server/conftest.py new file mode 100644 index 00000000000..4141b28849c --- /dev/null +++ b/tests/components/traccar_server/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Traccar Server 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.traccar_server.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py new file mode 100644 index 00000000000..028bc99cec5 --- /dev/null +++ b/tests/components/traccar_server/test_config_flow.py @@ -0,0 +1,315 @@ +"""Test the Traccar Server config flow.""" +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from pytraccar import TraccarException + +from homeassistant import config_entries +from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.traccar_server.const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, + EVENTS, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +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.traccar_server.config_flow.ApiClient.get_server", + return_value={"id": "1234"}, + ): + 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"] == "1.1.1.1:8082" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + ( + (TraccarException, "cannot_connect"), + (Exception, "unknown"), + ), +) +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.traccar_server.config_flow.ApiClient.get_server", + side_effect=side_effect, + ): + 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": error} + + with patch( + "homeassistant.components.traccar_server.config_flow.ApiClient.get_server", + return_value={"id": "1234"}, + ): + 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"] == "1.1.1.1:8082" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert CONF_MAX_ACCURACY not in config_entry.options + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MAX_ACCURACY: 2.0}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_MAX_ACCURACY: 2.0, + CONF_EVENTS: [], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + } + + +@pytest.mark.parametrize( + ("imported", "data", "options"), + ( + ( + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 443, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "443", + CONF_VERIFY_SSL: True, + CONF_SSL: False, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: [], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ( + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + "event": ["device_online", "device_offline"], + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_VERIFY_SSL: True, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: ["device_online", "device_offline"], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ( + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + "event": ["device_online", "device_offline", "all_events"], + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_VERIFY_SSL: True, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: list(EVENTS.values()), + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ), +) +async def test_import_from_yaml( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + imported: dict[str, Any], + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test importing configuration from YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=PLATFORM_SCHEMA({"platform": "traccar", **imported}), + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == f"{data[CONF_HOST]}:{data[CONF_PORT]}" + assert result["data"] == data + assert result["options"] == options + + +async def test_abort_import_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test abort for existing server while importing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"}, + ) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=PLATFORM_SCHEMA( + { + "platform": "traccar", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + } + ), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_abort_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test abort for existing server.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"}, + ) + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + 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_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 1197719328b..511667a7462 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -426,7 +426,7 @@ async def test_restore_traces( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client, domain ) -> None: """Test restored traces.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 def next_id(): @@ -598,7 +598,7 @@ async def test_restore_traces_overflow( num_restored_moon_traces, ) -> None: """Test restored traces are evicted first.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 trace_uuids = [] @@ -679,7 +679,7 @@ async def test_restore_traces_late_overflow( restored_run_id, ) -> None: """Test restored traces are evicted first.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 trace_uuids = [] diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py index 6cd1a4efca8..6dd6f119d45 100644 --- a/tests/components/tractive/test_config_flow.py +++ b/tests/components/tractive/test_config_flow.py @@ -24,9 +24,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] is None - with patch( - "aiotractive.api.API.user_id", return_value={"user_id": "user_id"} - ), patch( + with patch("aiotractive.api.API.user_id", return_value="user_id"), patch( "homeassistant.components.tractive.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a5eeb707b34..92693ccf3c2 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -70,6 +70,65 @@ def fixture_get_camera() -> CameraInfo: ) +@pytest.fixture(name="get_camera2") +def fixture_get_camera2() -> CameraInfo: + """Construct Camera Mock 2.""" + + return CameraInfo( + camera_name="Test Camera2", + camera_id="5678", + active=True, + deleted=False, + description="Test Camera for testing2", + direction="180", + fullsizephoto=True, + location="Test location2", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo2.jpg", + status="Running", + camera_type="Road", + ) + + +@pytest.fixture(name="get_cameras") +def fixture_get_cameras() -> CameraInfo: + """Construct Camera Mock with multiple cameras.""" + + return [ + CameraInfo( + camera_name="Test Camera", + camera_id="1234", + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location="Test location", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ), + CameraInfo( + camera_name="Test Camera2", + camera_id="5678", + active=True, + deleted=False, + description="Test Camera for testing2", + direction="180", + fullsizephoto=True, + location="Test location2", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo2.jpg", + status="Running", + camera_type="Road", + ), + ] + + @pytest.fixture(name="get_camera_no_location") def fixture_get_camera_no_location() -> CameraInfo: """Construct Camera Mock.""" diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index ca1d8554c4a..005c6006d81 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -4,12 +4,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( - InvalidAuthentication, - MultipleCamerasFound, - NoCameraFound, - UnknownError, -) +from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries @@ -31,8 +26,8 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", - return_value=get_camera, + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera], ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -56,6 +51,55 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: assert result2["result"].unique_id == "trafikverket_camera-1234" +async def test_form_multiple_cameras( + hass: HomeAssistant, get_cameras: list[CameraInfo], get_camera2: CameraInfo +) -> None: + """Test we get the form with multiple cameras.""" + + 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.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=get_cameras, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test loc", + }, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera2], + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Camera2" + assert result["data"] == { + "api_key": "1234567890", + "id": "5678", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "trafikverket_camera-5678" + + async def test_form_no_location_data( hass: HomeAssistant, get_camera_no_location: CameraInfo ) -> None: @@ -68,8 +112,8 @@ async def test_form_no_location_data( assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", - return_value=get_camera_no_location, + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera_no_location], ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -106,11 +150,6 @@ async def test_form_no_location_data( "location", "invalid_location", ), - ( - MultipleCamerasFound, - "location", - "more_locations", - ), ( UnknownError, "base", @@ -130,7 +169,7 @@ async def test_flow_fails( assert result4["step_id"] == config_entries.SOURCE_USER with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", side_effect=side_effect, ): result4 = await hass.config_entries.flow.async_configure( @@ -171,7 +210,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -203,11 +242,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "location", "invalid_location", ), - ( - MultipleCamerasFound, - "location", - "more_locations", - ), ( UnknownError, "base", @@ -242,7 +276,7 @@ async def test_reauth_flow_error( ) with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( @@ -256,7 +290,7 @@ async def test_reauth_flow_error( assert result2["errors"] == {error_key: p_error} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 990d8d273ed..d56542b2a57 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -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/conftest.py b/tests/components/tuya/conftest.py new file mode 100644 index 00000000000..6decb7c5f10 --- /dev/null +++ b/tests/components/tuya/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for the Tuya integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock an old config entry that can be migrated.""" + return MockConfigEntry( + title="Old Tuya configuration entry", + domain=DOMAIN, + data={CONF_APP_TYPE: "tuyaSmart"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock an config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_USER_CODE: "12345"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_tuya_login_control() -> Generator[MagicMock, None, None]: + """Return a mocked Tuya login control.""" + with patch( + "homeassistant.components.tuya.config_flow.LoginControl", autospec=True + ) as login_control_mock: + login_control = login_control_mock.return_value + login_control.qr_code.return_value = { + "success": True, + "result": { + "qrcode": "mocked_qr_code", + }, + } + login_control.login_result.return_value = ( + True, + { + "t": "mocked_t", + "uid": "mocked_uid", + "username": "mocked_username", + "expire_time": "mocked_expire_time", + "access_token": "mocked_access_token", + "refresh_token": "mocked_refresh_token", + "terminal_id": "mocked_terminal_id", + "endpoint": "mocked_endpoint", + }, + ) + yield login_control diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..416a656c238 --- /dev/null +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -0,0 +1,112 @@ +# serializer version: 1 +# name: test_reauth_flow + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_reauth_flow_migration + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Old Tuya configuration entry', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tuya', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'mocked_username', + 'unique_id': None, + 'version': 1, + }), + 'title': 'mocked_username', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index f8345683d4a..c38d8e5f8b5 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,127 +1,267 @@ """Tests for the Tuya config flow.""" from __future__ import annotations -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from tuya_iot import TuyaCloudOpenAPIEndpoint +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_ENDPOINT, - DOMAIN, - SMARTLIFE_APP, - TUYA_COUNTRIES, - TUYA_SMART_APP, -) -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -MOCK_SMART_HOME_PROJECT_TYPE = 0 -MOCK_INDUSTRY_PROJECT_TYPE = 1 +from tests.common import MockConfigEntry -MOCK_COUNTRY = "India" -MOCK_ACCESS_ID = "myAccessId" -MOCK_ACCESS_SECRET = "myAccessSecret" -MOCK_USERNAME = "myUsername" -MOCK_PASSWORD = "myPassword" -MOCK_ENDPOINT = TuyaCloudOpenAPIEndpoint.INDIA - -TUYA_INPUT_DATA = { - CONF_COUNTRY_CODE: MOCK_COUNTRY, - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, -} - -RESPONSE_SUCCESS = { - "success": True, - "code": 1024, - "result": {"platform_url": MOCK_ENDPOINT}, -} -RESPONSE_ERROR = {"success": False, "code": 123, "msg": "Error"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@pytest.fixture(name="tuya") -def tuya_fixture() -> MagicMock: - """Patch libraries.""" - with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: - yield tuya - - -@pytest.fixture(name="tuya_setup", autouse=True) -def tuya_setup_fixture() -> None: - """Mock tuya entry setup.""" - with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): - yield - - -@pytest.mark.parametrize( - ("app_type", "side_effects", "project_type"), - [ - ("", [RESPONSE_SUCCESS], 1), - (TUYA_SMART_APP, [RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - (SMARTLIFE_APP, [RESPONSE_ERROR, RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - ], -) +@pytest.mark.usefixtures("mock_tuya_login_control") async def test_user_flow( hass: HomeAssistant, - tuya: MagicMock, - app_type: str, - side_effects: list[dict[str, Any]], - project_type: int, -): - """Test user flow.""" + 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": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" - tuya().connect = MagicMock(side_effect=side_effects) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, ) - await hass.async_block_till_done() - country = [country for country in TUYA_COUNTRIES if country.name == MOCK_COUNTRY][0] + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_USERNAME - assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID - assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_ENDPOINT] == country.endpoint - assert result["data"][CONF_APP_TYPE] == app_type - assert result["data"][CONF_AUTH_TYPE] == project_type - assert result["data"][CONF_COUNTRY_CODE] == country.country_code - assert not result["result"].unique_id + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.CREATE_ENTRY + assert result3 == snapshot -async def test_error_on_invalid_credentials(hass: HomeAssistant, tuya) -> None: - """Test when we have invalid credentials.""" +async def test_user_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while retrieving the QR code.""" + 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" + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_failed_scan( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while verifying login.""" + 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_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + + # Access has been denied, or the code hasn't been scanned yet + good_values = mock_tuya_login_control.login_result.return_value + mock_tuya_login_control.login_result.return_value = ( + False, + {"msg": "oops", "code": 42}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.FORM + assert result3.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.login_result.return_value = good_values + + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result4.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + 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["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "scan" - tuya().connect = MagicMock(return_value=RESPONSE_ERROR) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, ) - await hass.async_block_till_done() - assert result["errors"]["base"] == "login_error" - assert result["description_placeholders"]["code"] == RESPONSE_ERROR["code"] - assert result["description_placeholders"]["msg"] == RESPONSE_ERROR["msg"] + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry == snapshot + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow_migration( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow. + + This flow tests the migration from an old config entry. + """ + mock_old_config_entry.add_to_hass(hass) + + # Ensure old data is there, new data is missing + assert CONF_APP_TYPE in mock_old_config_entry.data + assert CONF_USER_CODE not in mock_old_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_user_code" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" + + # Ensure the old data is gone, new data is present + assert CONF_APP_TYPE not in mock_old_config_entry.data + assert CONF_USER_CODE in mock_old_config_entry.data + + assert mock_old_config_entry == snapshot + + +async def test_reauth_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test an error occurring while retrieving the QR code.""" + mock_old_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index d48ff613902..0cddd505cd4 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -121,6 +121,7 @@ def mock_device_registry(hass): "00:00:00:00:00:03", "00:00:00:00:00:04", "00:00:00:00:00:05", + "00:00:00:00:00:06", "00:00:00:00:01:01", "00:00:00:00:02:02", ) diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 268f4e8493a..8953351f9fe 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -461,11 +461,11 @@ async def test_get_unifi_controller_verify_ssl_false(hass: HomeAssistant) -> Non [ (asyncio.TimeoutError, CannotConnect), (aiounifi.BadGateway, CannotConnect), + (aiounifi.Forbidden, CannotConnect), (aiounifi.ServiceUnavailable, CannotConnect), (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_sensor.py b/tests/components/unifi/test_sensor.py index 6eb6c05209c..9ebdd207b54 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -22,6 +22,7 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, DEVICE_STATES, + DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -393,6 +394,31 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" assert hass.states.get("sensor.wireless_client_tx").state == "7891.0" + # Verify reset sensor after heartbeat expires + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + new_time = dt_util.utcnow() + wireless_client["last_seen"] = dt_util.as_timestamp(new_time) + + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) + await hass.async_block_till_done() + + with freeze_time(new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" + assert hass.states.get("sensor.wireless_client_tx").state == "7891.0" + + new_time = new_time + controller.option_detection_time + timedelta(seconds=1) + + with freeze_time(new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wireless_client_rx").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.wireless_client_tx").state == STATE_UNAVAILABLE + # Disable option options[CONF_ALLOW_BANDWIDTH_SENSORS] = False diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 4b86d4912c1..2c6a7c90065 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -78,11 +78,11 @@ async def test_binary_sensor_sensor_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 5, 5) await remove_entities(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) await adopt_devices(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 5, 5) async def test_binary_sensor_setup_light( @@ -201,11 +201,18 @@ async def test_binary_sensor_setup_sensor( """Test binary_sensor entity setup for sensor devices.""" await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) entity_registry = er.async_get(hass) - for description in SENSE_SENSORS_WRITE: + expected = [ + STATE_OFF, + STATE_UNAVAILABLE, + STATE_OFF, + STATE_OFF, + STATE_OFF, + ] + for index, description in enumerate(SENSE_SENSORS_WRITE): unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, description ) @@ -216,24 +223,25 @@ async def test_binary_sensor_setup_sensor( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OFF + assert state.state == expected[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_binary_sensor_setup_sensor_none( +async def test_binary_sensor_setup_sensor_leak( hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor ) -> None: - """Test binary_sensor entity setup for sensor with most sensors disabled.""" + """Test binary_sensor entity setup for sensor with most leak mounting type.""" sensor.mount_type = MountType.LEAK await init_entry(hass, ufp, [sensor]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) entity_registry = er.async_get(hass) expected = [ STATE_UNAVAILABLE, STATE_OFF, + STATE_OFF, STATE_UNAVAILABLE, STATE_OFF, ] @@ -348,7 +356,7 @@ async def test_binary_sensor_update_mount_type_window( """Test binary_sensor motion entity.""" await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] @@ -379,8 +387,8 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + await init_entry(hass, ufp, [sensor_all], debug=True) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 854109bee6d..6af636ef448 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -226,9 +226,10 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: result2["flow_id"], { "username": "test-username", - "password": "test-password", + "password": "new-password", }, ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2a0a0eb0655..1ade39dafca 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,6 +25,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -161,6 +162,7 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, + debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -168,6 +170,14 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) + if debug: + assert await async_setup_component(hass, "logger", {"logger": {}}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.unifiprotect": "DEBUG"}, + blocking=True, + ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 262dbf36306..75ea6d3a4d2 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -49,6 +49,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": [], } @@ -63,6 +64,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": [], } @@ -100,6 +102,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "periodically_resetting": True, + "always_available": False, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -114,6 +117,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -173,6 +177,7 @@ async def test_non_periodically_resetting(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "periodically_resetting": False, + "always_available": False, "offset": 0, "source": input_sensor_entity_id, "tariffs": [], @@ -187,6 +192,61 @@ async def test_non_periodically_resetting(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": False, + "source": input_sensor_entity_id, + "tariffs": [], + } + + +async def test_always_available(hass: HomeAssistant) -> None: + """Test sensor always available.""" + input_sensor_entity_id = "sensor.input" + + 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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + "always_available": True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "periodically_resetting": False, + "always_available": True, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": [], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "always_available": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -237,7 +297,11 @@ async def test_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, + user_input={ + "source": input_sensor2_entity_id, + "periodically_resetting": False, + "always_available": True, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -247,6 +311,7 @@ async def test_options(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": True, "source": input_sensor2_entity_id, "tariffs": "", } @@ -258,6 +323,7 @@ async def test_options(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": True, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index d77c2db356a..fa1e3aa8785 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -231,6 +231,106 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.state == "unavailable" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "always_available": True, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": [], + "always_available": True, + }, + ), + ), +) +async def test_state_always_available( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility sensor state.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + now = dt_util.utcnow() + timedelta(seconds=10) + with freeze_time(now): + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + assert state.attributes.get("status") == COLLECTING + + # test unavailable state + hass.states.async_set( + entity_id, + "unavailable", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + + # test unknown state + hass.states.async_set( + entity_id, None, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + + @pytest.mark.parametrize( "yaml_config", ( @@ -534,7 +634,7 @@ async def test_restore_state( ) -> None: """Test utility sensor restore state.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) last_reset = "2020-12-21T00:00:00.013073+00:00" @@ -865,7 +965,7 @@ async def test_delta_values( ) -> None: """Test utility meter "delta_values" mode.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) now = dt_util.utcnow() with freeze_time(now): @@ -974,7 +1074,7 @@ async def test_non_periodically_resetting( ) -> None: """Test utility meter "non periodically resetting" mode.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) now = dt_util.utcnow() with freeze_time(now): @@ -1460,6 +1560,7 @@ def test_calculate_adjustment_invalid_new_state( net_consumption=False, parent_meter="sensor.test", periodically_resetting=True, + sensor_always_available=False, unique_id="test_utility_meter", source_entity="sensor.test", tariff=None, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 0b44476989b..0da4470c762 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -1,147 +1,41 @@ """The tests for the Vacuum entity integration.""" from __future__ import annotations -from collections.abc import Generator - -import pytest - -from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, - VacuumEntity, - VacuumEntityFeature, -) -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -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.""" +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" - -@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 - - -ISSUE_TRACKER = "https://blablabla.com" - - -@pytest.mark.parametrize( - ("manifest_extra", "translation_key", "translation_placeholders_extra"), - [ - ( - {}, - "deprecated_vacuum_base_class", - {}, - ), - ( - {"issue_tracker": ISSUE_TRACKER}, - "deprecated_vacuum_base_class_url", - {"issue_tracker": ISSUE_TRACKER}, - ), - ], -) -async def test_deprecated_base_class( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - manifest_extra: dict[str, str], - translation_key: str, - translation_placeholders_extra: dict[str, str], -) -> None: - """Test warnings when adding VacuumEntity to the state machine.""" - - 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, VACUUM_DOMAIN) - return True - - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - entity1 = VacuumEntity() - entity1.entity_id = "vacuum.test1" + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test vacuum platform via config entry.""" - async_add_entities([entity1]) - - mock_platform( - hass, - f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + entity = _LegacyConstantsStateVacuum() + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get(entity1.entity_id) - - assert ( - "test::VacuumEntity is extending the deprecated base class VacuumEntity" - in caplog.text - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - ) - assert issue.issue_domain == TEST_DOMAIN - assert issue.issue_id == f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - assert issue.translation_key == translation_key - assert ( - 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 + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported diff --git a/tests/components/venstar/test_climate.py b/tests/components/venstar/test_climate.py index 8aa3065e3c4..d7c28b953cc 100644 --- a/tests/components/venstar/test_climate.py +++ b/tests/components/venstar/test_climate.py @@ -10,6 +10,8 @@ EXPECTED_BASE_SUPPORTED_FEATURES = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 5085ff6661d..46d90960f4e 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -2,10 +2,12 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass from unittest.mock import AsyncMock, Mock, patch import pytest from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareService import ViCareDeviceAccessor, readFeature from homeassistant.components.vicare.const import DOMAIN from homeassistant.core import HomeAssistant @@ -15,16 +17,26 @@ from . import ENTRY_CONFIG, MODULE from tests.common import MockConfigEntry, load_json_object_fixture +@dataclass +class Fixture: + """Fixture representation with the assigned roles and dummy data location.""" + + roles: set[str] + data_file: str + + class MockPyViCare: """Mocked PyVicare class based on a json dump.""" - def __init__(self, fixtures: list[str]) -> None: + def __init__(self, fixtures: list[Fixture]) -> None: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): self.devices.append( PyViCareDeviceConfig( - MockViCareService(fixture), + MockViCareService( + f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture + ), f"deviceId{idx}", f"model{idx}", f"online{idx}", @@ -35,10 +47,22 @@ class MockPyViCare: class MockViCareService: """PyVicareService mock using a json dump.""" - def __init__(self, fixture: str) -> None: + def __init__( + self, installation_id: str, gateway_id: str, device_id: str, fixture: Fixture + ) -> None: """Initialize the mock from a json dump.""" - self._test_data = load_json_object_fixture(fixture) + self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.roles = fixture.roles + self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) + + def hasRoles(self, requested_roles: list[str]) -> bool: + """Return true if requested roles are assigned.""" + return requested_roles and set(requested_roles).issubset(self.roles) + + def getProperty(self, property_name: str): + """Read a property from json dump.""" + return readFeature(self._test_data["data"], property_name) @pytest.fixture @@ -57,7 +81,7 @@ async def mock_vicare_gas_boiler( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[MockConfigEntry, None]: """Return a mocked ViCare API representing a single gas boiler device.""" - fixtures = ["vicare/Vitodens300W.json"] + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with patch( f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures), diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2d08a50bf3f --- /dev/null +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -0,0 +1,42 @@ +# serializer version: 1 +# name: test_binary_sensors[burner] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 Burner', + 'icon': 'mdi:gas-burner', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_burner', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensors[circulation_pump] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 Circulation pump', + 'icon': 'mdi:pump', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_circulation_pump', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensors[frost_protection] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Frost protection', + 'icon': 'mdi:snowflake', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_frost_protection', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/vicare/test_binary_sensor.py b/tests/components/vicare/test_binary_sensor.py new file mode 100644 index 00000000000..79ce91642af --- /dev/null +++ b/tests/components/vicare/test_binary_sensor.py @@ -0,0 +1,26 @@ +"""Test ViCare binary sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + "entity_id", + [ + "burner", + "circulation_pump", + "frost_protection", + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + mock_vicare_gas_boiler: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test the ViCare binary sensor.""" + assert hass.states.get(f"binary_sensor.model0_{entity_id}") == snapshot diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 660de3ff6b6..142c5f74b84 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -6,6 +6,7 @@ from datetime import timedelta 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/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 00b1ae6e72a..a50bde8de64 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -4,6 +4,8 @@ from unittest.mock import patch from aiovodafone import exceptions as aiovodafone_exceptions import pytest +from homeassistant import data_entry_flow +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -23,11 +25,7 @@ async def test_user(hass: HomeAssistant) -> None: "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,8 +61,8 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" with patch( "aiovodafone.api.VodafoneStationSercommApi.login", @@ -76,6 +74,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] is not None assert result["errors"]["base"] == error # Should be recoverable after hits error @@ -123,11 +122,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry", - ), patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, @@ -190,6 +185,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["errors"] is not None assert result["errors"]["base"] == error # Should be recoverable after hits error @@ -216,3 +212,25 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } diff --git a/tests/components/vulcan/conftest.py b/tests/components/vulcan/conftest.py deleted file mode 100644 index 05a518ad7f3..00000000000 --- a/tests/components/vulcan/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/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 990482c500e..713130b6fb6 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -150,7 +150,7 @@ async def test_webhook_allowed_methods_internet( "platform": "webhook", "webhook_id": "post_webhook", "allowed_methods": "PUT", - # Enable after 2023.11.0: "local_only": False, + "local_only": False, }, "action": { "event": "test_success", diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index dd18342abec..65cf3012e30 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -134,7 +134,7 @@ async def test_auth_active_user_inactive( hass_access_token: str, ) -> None: """Test authenticating with a token.""" - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() @@ -216,8 +216,8 @@ async def test_auth_close_after_revoke( """Test that a websocket is closed after the refresh token is revoked.""" assert not websocket_client.closed - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - await hass.auth.async_remove_refresh_token(refresh_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + hass.auth.async_remove_refresh_token(refresh_token) msg = await websocket_client.receive() assert msg.type == aiohttp.WSMsgType.CLOSED diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 127b45484be..9db74b9a857 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -716,22 +716,21 @@ async def test_get_config( assert msg["type"] == const.TYPE_RESULT assert msg["success"] - if "components" in msg["result"]: - msg["result"]["components"] = set(msg["result"]["components"]) - if "whitelist_external_dirs" in msg["result"]: - msg["result"]["whitelist_external_dirs"] = set( - msg["result"]["whitelist_external_dirs"] - ) - if "allowlist_external_dirs" in msg["result"]: - msg["result"]["allowlist_external_dirs"] = set( - msg["result"]["allowlist_external_dirs"] - ) - if "allowlist_external_urls" in msg["result"]: - msg["result"]["allowlist_external_urls"] = set( - msg["result"]["allowlist_external_urls"] - ) + result = msg["result"] + ignore_order_keys = ( + "components", + "allowlist_external_dirs", + "whitelist_external_dirs", + "allowlist_external_urls", + ) + config = hass.config.as_dict() - assert msg["result"] == hass.config.as_dict() + for key in ignore_order_keys: + if key in result: + result[key] = set(result[key]) + config[key] = set(config[key]) + + assert result == config async def test_ping(websocket_client: MockHAClientWebSocket) -> None: @@ -776,7 +775,7 @@ async def test_call_service_context_with_user( msg = await ws.receive_json() assert msg["success"] - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(calls) == 1 call = calls[0] @@ -804,6 +803,7 @@ async def test_states_filters_visible( hass: HomeAssistant, hass_admin_user: MockUser, websocket_client ) -> None: """Test we only get entities that we're allowed to see.""" + hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") @@ -1048,6 +1048,7 @@ async def test_subscribe_unsubscribe_entities( } hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) + assert not hass_admin_user.is_admin await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 35ed55183d4..5fc0e4ea315 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -5,7 +5,7 @@ from homeassistant.components.websocket_api.messages import ( _partial_cached_event_message as lru_event_cache, _state_diff_event, cached_event_message, - message_to_json, + message_to_json_bytes, ) from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Context, Event, HomeAssistant, State, callback @@ -237,19 +237,63 @@ 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) -async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: + 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_bytes(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" - json_str = message_to_json({"id": 1, "message": "xyz"}) + json_str = message_to_json_bytes({"id": 1, "message": "xyz"}) - assert json_str == '{"id":1,"message":"xyz"}' + assert json_str == b'{"id":1,"message":"xyz"}' - json_str2 = message_to_json({"id": 1, "message": _Unserializeable()}) + json_str2 = message_to_json_bytes({"id": 1, "message": _Unserializeable()}) assert ( json_str2 - == '{"id":1,"type":"result","success":false,"error":{"code":"unknown_error","message":"Invalid JSON in response"}}' + == b'{"id":1,"type":"result","success":false,"error":{"code":"unknown_error","message":"Invalid JSON in response"}}' ) assert "Unable to serialize to JSON" in caplog.text diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 8607a49b42c..0cc58e80f0d 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -97,6 +97,8 @@ async def test_static_attributes( == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 3155d588e14..20d0c436134 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -49,7 +49,7 @@ async def test_dryer_sensor_values( entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -113,7 +113,7 @@ async def test_washer_sensor_values( entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -280,7 +280,7 @@ async def test_restore_state( ) -> None: """Test sensor restore state.""" # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -333,7 +333,7 @@ async def test_callback( mock_sensor1_api: MagicMock, ) -> None: """Test callback timestamp callback function.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, diff --git a/tests/components/withings/fixtures/measurements.json b/tests/components/withings/fixtures/measurements.json index 3ed59a7c3f4..03222521877 100644 --- a/tests/components/withings/fixtures/measurements.json +++ b/tests/components/withings/fixtures/measurements.json @@ -108,6 +108,26 @@ "type": 169, "unit": 0, "value": 100 + }, + { + "type": 198, + "unit": 0, + "value": 102 + }, + { + "type": 197, + "unit": 0, + "value": 102 + }, + { + "type": 196, + "unit": 0, + "value": 102 + }, + { + "type": 170, + "unit": 0, + "value": 102 } ], "modelid": 45, diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index f9b4a1d9bba..3dc7e824230 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -25,6 +25,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, @@ -57,6 +61,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, @@ -89,6 +97,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 4ca4093e3b8..f84fe05bb78 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,5 +1,41 @@ # serializer version: 1 -# name: test_all_entities[sensor.henk_active_calories_burnt_today] +# name: test_all_entities[sensor.henk_active_calories_burnt_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_active_calories_burnt_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active calories burnt today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_active_calories_burnt_today', + 'unique_id': 'withings_12345_activity_active_calories_burnt_today', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_active_calories_burnt_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Active calories burnt today', @@ -14,27 +50,95 @@ 'state': '221.132', }) # --- -# name: test_all_entities[sensor.henk_active_time_today] +# name: test_all_entities[sensor.henk_active_time_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_active_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active time today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_active_duration_today', + 'unique_id': 'withings_12345_activity_active_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_active_time_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Active time today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_active_time_today', 'last_changed': , 'last_updated': , - 'state': '1907', + 'state': '0.530', }) # --- -# name: test_all_entities[sensor.henk_average_heart_rate] +# name: test_all_entities[sensor.henk_average_heart_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_average_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_average_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -45,7 +149,40 @@ 'state': '103', }) # --- -# name: test_all_entities[sensor.henk_average_respiratory_rate] +# name: test_all_entities[sensor.henk_average_respiratory_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_average_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_average_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average respiratory rate', @@ -59,7 +196,40 @@ 'state': '14', }) # --- -# name: test_all_entities[sensor.henk_body_temperature] +# name: test_all_entities[sensor.henk_body_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.henk_body_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Body temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'body_temperature', + 'unique_id': 'withings_12345_body_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_body_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -74,12 +244,47 @@ 'state': '40', }) # --- -# name: test_all_entities[sensor.henk_bone_mass] +# name: test_all_entities[sensor.henk_bone_mass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_bone_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bone mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bone_mass', + 'unique_id': 'withings_12345_bone_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_bone_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', 'friendly_name': 'henk Bone mass', - 'icon': 'mdi:bone', 'state_class': , 'unit_of_measurement': , }), @@ -90,7 +295,40 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.henk_breathing_disturbances_intensity] +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Breathing disturbances intensity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'breathing_disturbances_intensity', + 'unique_id': 'withings_12345_sleep_breathing_disturbances_intensity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Breathing disturbances intensity', @@ -103,7 +341,41 @@ 'state': '9', }) # --- -# name: test_all_entities[sensor.henk_calories_burnt_last_workout] +# name: test_all_entities[sensor.henk_calories_burnt_last_workout-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.henk_calories_burnt_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calories burnt last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_active_calories_burnt', + 'unique_id': 'withings_12345_workout_active_calories_burnt', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Calories burnt last workout', @@ -116,12 +388,44 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.henk_deep_sleep] +# name: test_all_entities[sensor.henk_deep_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_deep_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deep sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deep_sleep', + 'unique_id': 'withings_12345_sleep_deep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_deep_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Deep sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -132,7 +436,40 @@ 'state': '5820', }) # --- -# name: test_all_entities[sensor.henk_diastolic_blood_pressure] +# name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Diastolic blood pressure', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'diastolic_blood_pressure', + 'unique_id': 'withings_12345_diastolic_blood_pressure_mmhg', + 'unit_of_measurement': 'mmhg', + }) +# --- +# name: test_all_entities[sensor.henk_diastolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Diastolic blood pressure', @@ -146,12 +483,45 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_distance_travelled_last_workout] +# name: test_all_entities[sensor.henk_distance_travelled_last_workout-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.henk_distance_travelled_last_workout', + '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': 'Distance travelled last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_distance', + 'unique_id': 'withings_12345_workout_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Distance travelled last workout', - 'icon': 'mdi:map-marker-distance', 'unit_of_measurement': , }), 'context': , @@ -161,12 +531,47 @@ 'state': '232', }) # --- -# name: test_all_entities[sensor.henk_distance_travelled_today] +# name: test_all_entities[sensor.henk_distance_travelled_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_distance_travelled_today', + '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': 'Distance travelled today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_distance_today', + 'unique_id': 'withings_12345_activity_distance_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Distance travelled today', - 'icon': 'mdi:map-marker-distance', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , 'unit_of_measurement': , @@ -178,12 +583,174 @@ 'state': '1020.121', }) # --- -# name: test_all_entities[sensor.henk_elevation_change_last_workout] +# name: test_all_entities[sensor.henk_electrodermal_activity_feet-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.henk_electrodermal_activity_feet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity feet', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_feet', + 'unique_id': 'withings_12345_electrodermal_activity_feet', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_feet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity feet', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_feet', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_left_foot-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.henk_electrodermal_activity_left_foot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity left foot', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_left_foot', + 'unique_id': 'withings_12345_electrodermal_activity_left_foot', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_left_foot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity left foot', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_left_foot', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_right_foot-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.henk_electrodermal_activity_right_foot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity right foot', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_right_foot', + 'unique_id': 'withings_12345_electrodermal_activity_right_foot', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_right_foot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity right foot', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_right_foot', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout-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.henk_elevation_change_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elevation change last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_elevation', + 'unique_id': 'withings_12345_workout_floors_climbed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Elevation change last workout', - 'icon': 'mdi:stairs-up', 'unit_of_measurement': , }), 'context': , @@ -193,12 +760,44 @@ 'state': '4', }) # --- -# name: test_all_entities[sensor.henk_elevation_change_today] +# name: test_all_entities[sensor.henk_elevation_change_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_elevation_change_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elevation change today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_elevation_today', + 'unique_id': 'withings_12345_activity_floors_climbed_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_today-state] 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': , @@ -210,7 +809,40 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.henk_extracellular_water] +# name: test_all_entities[sensor.henk_extracellular_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_extracellular_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extracellular water', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'extracellular_water', + 'unique_id': 'withings_12345_extracellular_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_extracellular_water-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -225,7 +857,43 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_fat_free_mass] +# name: test_all_entities[sensor.henk_fat_free_mass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass', + 'unique_id': 'withings_12345_fat_free_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -240,7 +908,43 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.henk_fat_mass] +# name: test_all_entities[sensor.henk_fat_mass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass', + 'unique_id': 'withings_12345_fat_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -255,7 +959,43 @@ 'state': '5', }) # --- -# name: test_all_entities[sensor.henk_fat_ratio] +# name: test_all_entities[sensor.henk_fat_ratio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_ratio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fat ratio', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_ratio', + 'unique_id': 'withings_12345_fat_ratio_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_fat_ratio-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Fat ratio', @@ -269,11 +1009,43 @@ 'state': '0.07', }) # --- -# name: test_all_entities[sensor.henk_heart_pulse] +# name: test_all_entities[sensor.henk_heart_pulse-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_heart_pulse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heart pulse', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heart_pulse', + 'unique_id': 'withings_12345_heart_pulse_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_heart_pulse-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Heart pulse', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -284,7 +1056,43 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.henk_height] +# name: test_all_entities[sensor.henk_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_height', + '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': 'Height', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'height', + 'unique_id': 'withings_12345_height_m', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -299,12 +1107,44 @@ 'state': '2', }) # --- -# name: test_all_entities[sensor.henk_hydration] +# name: test_all_entities[sensor.henk_hydration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_hydration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydration', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hydration', + 'unique_id': 'withings_12345_hydration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_hydration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', 'friendly_name': 'henk Hydration', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': , }), @@ -315,23 +1155,92 @@ 'state': '0.95', }) # --- -# name: test_all_entities[sensor.henk_intense_activity_today] +# name: test_all_entities[sensor.henk_intense_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_intense_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intense activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_intense_duration_today', + 'unique_id': 'withings_12345_activity_intense_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_intense_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Intense activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_intense_activity_today', 'last_changed': , 'last_updated': , - 'state': '420', + 'state': '7.0', }) # --- -# name: test_all_entities[sensor.henk_intracellular_water] +# name: test_all_entities[sensor.henk_intracellular_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_intracellular_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intracellular water', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intracellular_water', + 'unique_id': 'withings_12345_intracellular_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_intracellular_water-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -346,22 +1255,86 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_last_workout_duration] +# name: test_all_entities[sensor.henk_last_workout_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_last_workout_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last workout duration', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_duration', + 'unique_id': 'withings_12345_workout_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Last workout duration', - 'icon': 'mdi:timer', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_last_workout_duration', 'last_changed': , 'last_updated': , - 'state': '255.0', + 'state': '4.25', }) # --- -# name: test_all_entities[sensor.henk_last_workout_intensity] +# name: test_all_entities[sensor.henk_last_workout_intensity-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.henk_last_workout_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last workout intensity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_intensity', + 'unique_id': 'withings_12345_workout_intensity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Last workout intensity', @@ -373,7 +1346,90 @@ 'state': '30', }) # --- -# name: test_all_entities[sensor.henk_last_workout_type] +# name: test_all_entities[sensor.henk_last_workout_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_last_workout_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last workout type', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_type', + 'unique_id': 'withings_12345_workout_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_type-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -437,12 +1493,44 @@ 'state': 'walk', }) # --- -# name: test_all_entities[sensor.henk_light_sleep] +# name: test_all_entities[sensor.henk_light_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_light_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_sleep', + 'unique_id': 'withings_12345_sleep_light_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_light_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Light sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -453,11 +1541,43 @@ 'state': '10440', }) # --- -# name: test_all_entities[sensor.henk_maximum_heart_rate] +# name: test_all_entities[sensor.henk_maximum_heart_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_maximum_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -468,7 +1588,40 @@ 'state': '120', }) # --- -# name: test_all_entities[sensor.henk_maximum_respiratory_rate] +# name: test_all_entities[sensor.henk_maximum_respiratory_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum respiratory rate', @@ -482,11 +1635,43 @@ 'state': '20', }) # --- -# name: test_all_entities[sensor.henk_minimum_heart_rate] +# name: test_all_entities[sensor.henk_minimum_heart_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_minimum_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -497,7 +1682,40 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_minimum_respiratory_rate] +# name: test_all_entities[sensor.henk_minimum_respiratory_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum respiratory rate', @@ -511,23 +1729,95 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.henk_moderate_activity_today] +# name: test_all_entities[sensor.henk_moderate_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_moderate_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moderate activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_moderate_duration_today', + 'unique_id': 'withings_12345_activity_moderate_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_moderate_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Moderate activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_moderate_activity_today', 'last_changed': , 'last_updated': , - 'state': '1487', + 'state': '24.8', }) # --- -# name: test_all_entities[sensor.henk_muscle_mass] +# name: test_all_entities[sensor.henk_muscle_mass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass', + 'unique_id': 'withings_12345_muscle_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -542,22 +1832,88 @@ 'state': '50', }) # --- -# name: test_all_entities[sensor.henk_pause_during_last_workout] +# name: test_all_entities[sensor.henk_pause_during_last_workout-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.henk_pause_during_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pause during last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_pause_duration', + 'unique_id': 'withings_12345_workout_pause_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_pause_during_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Pause during last workout', - 'icon': 'mdi:timer-pause', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_pause_during_last_workout', 'last_changed': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- -# name: test_all_entities[sensor.henk_pulse_wave_velocity] +# name: test_all_entities[sensor.henk_pulse_wave_velocity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_pulse_wave_velocity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pulse wave velocity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pulse_wave_velocity', + 'unique_id': 'withings_12345_pulse_wave_velocity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_pulse_wave_velocity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speed', @@ -572,12 +1928,44 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_rem_sleep] +# name: test_all_entities[sensor.henk_rem_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_rem_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'REM sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rem_sleep', + 'unique_id': 'withings_12345_sleep_rem_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_rem_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk REM sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -588,7 +1976,40 @@ 'state': '2400', }) # --- -# name: test_all_entities[sensor.henk_skin_temperature] +# name: test_all_entities[sensor.henk_skin_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.henk_skin_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Skin temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'skin_temperature', + 'unique_id': 'withings_12345_skin_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_skin_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -603,27 +2024,94 @@ 'state': '20', }) # --- -# name: test_all_entities[sensor.henk_sleep_goal] +# name: test_all_entities[sensor.henk_sleep_goal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_sleep_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_goal', + 'unique_id': 'withings_12345_sleep_goal', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_sleep_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Sleep goal', - 'icon': 'mdi:bed-clock', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_sleep_goal', 'last_changed': , 'last_updated': , - 'state': '28800', + 'state': '8.000', }) # --- -# name: test_all_entities[sensor.henk_sleep_score] +# name: test_all_entities[sensor.henk_sleep_score-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_sleep_score', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep score', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_score', + 'unique_id': 'withings_12345_sleep_score', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_all_entities[sensor.henk_sleep_score-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Sleep score', - 'icon': 'mdi:medal', 'state_class': , 'unit_of_measurement': 'points', }), @@ -634,7 +2122,40 @@ 'state': '37', }) # --- -# name: test_all_entities[sensor.henk_snoring] +# name: test_all_entities[sensor.henk_snoring-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_snoring', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snoring', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'snoring', + 'unique_id': 'withings_12345_sleep_snoring', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_snoring-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring', @@ -647,7 +2168,40 @@ 'state': '1080', }) # --- -# name: test_all_entities[sensor.henk_snoring_episode_count] +# name: test_all_entities[sensor.henk_snoring_episode_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_snoring_episode_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snoring episode count', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'snoring_episode_count', + 'unique_id': 'withings_12345_sleep_snoring_eposode_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_snoring_episode_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring episode count', @@ -660,23 +2214,92 @@ 'state': '18', }) # --- -# name: test_all_entities[sensor.henk_soft_activity_today] +# name: test_all_entities[sensor.henk_soft_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_soft_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Soft activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_soft_duration_today', + 'unique_id': 'withings_12345_activity_soft_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_soft_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Soft activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_soft_activity_today', 'last_changed': , 'last_updated': , - 'state': '1516', + 'state': '25.3', }) # --- -# name: test_all_entities[sensor.henk_spo2] +# name: test_all_entities[sensor.henk_spo2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_spo2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SpO2', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spo2', + 'unique_id': 'withings_12345_spo2_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_spo2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk SpO2', @@ -690,11 +2313,43 @@ 'state': '0.95', }) # --- -# name: test_all_entities[sensor.henk_step_goal] +# name: test_all_entities[sensor.henk_step_goal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_step_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Step goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'step_goal', + 'unique_id': 'withings_12345_step_goal', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.henk_step_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Step goal', - 'icon': 'mdi:shoe-print', 'state_class': , 'unit_of_measurement': 'steps', }), @@ -705,11 +2360,43 @@ 'state': '10000', }) # --- -# name: test_all_entities[sensor.henk_steps_today] +# name: test_all_entities[sensor.henk_steps_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_steps_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steps today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_steps_today', + 'unique_id': 'withings_12345_activity_steps_today', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.henk_steps_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Steps today', - 'icon': 'mdi:shoe-print', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , 'unit_of_measurement': 'steps', @@ -721,7 +2408,40 @@ 'state': '1155', }) # --- -# name: test_all_entities[sensor.henk_systolic_blood_pressure] +# name: test_all_entities[sensor.henk_systolic_blood_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Systolic blood pressure', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'systolic_blood_pressure', + 'unique_id': 'withings_12345_systolic_blood_pressure_mmhg', + 'unit_of_measurement': 'mmhg', + }) +# --- +# name: test_all_entities[sensor.henk_systolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Systolic blood pressure', @@ -735,7 +2455,40 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_temperature] +# name: test_all_entities[sensor.henk_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.henk_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -750,12 +2503,44 @@ 'state': '40', }) # --- -# name: test_all_entities[sensor.henk_time_to_sleep] +# name: test_all_entities[sensor.henk_time_to_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_time_to_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_sleep', + 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_time_to_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Time to sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -766,12 +2551,44 @@ 'state': '540', }) # --- -# name: test_all_entities[sensor.henk_time_to_wakeup] +# name: test_all_entities[sensor.henk_time_to_wakeup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_time_to_wakeup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to wakeup', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_wakeup', + 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_time_to_wakeup-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Time to wakeup', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': , }), @@ -782,7 +2599,43 @@ 'state': '1140', }) # --- -# name: test_all_entities[sensor.henk_total_calories_burnt_today] +# name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_total_calories_burnt_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total calories burnt today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_total_calories_burnt_today', + 'unique_id': 'withings_12345_activity_total_calories_burnt_today', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_total_calories_burnt_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Total calories burnt today', @@ -797,7 +2650,38 @@ 'state': '2444.149', }) # --- -# name: test_all_entities[sensor.henk_vascular_age] +# name: test_all_entities[sensor.henk_vascular_age-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.henk_vascular_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vascular age', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vascular_age', + 'unique_id': 'withings_12345_vascular_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_vascular_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Vascular age', @@ -809,7 +2693,83 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_vo2_max] +# name: test_all_entities[sensor.henk_visceral_fat_index-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.henk_visceral_fat_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Visceral fat index', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'visceral_fat_index', + 'unique_id': 'withings_12345_visceral_fat', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_visceral_fat_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Visceral fat index', + }), + 'context': , + 'entity_id': 'sensor.henk_visceral_fat_index', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_vo2_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VO2 max', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vo2_max', + 'unique_id': 'withings_12345_vo2_max', + 'unit_of_measurement': 'ml/min/kg', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk VO2 max', @@ -823,11 +2783,43 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_wakeup_count] +# name: test_all_entities[sensor.henk_wakeup_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_wakeup_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wakeup count', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wakeup_count', + 'unique_id': 'withings_12345_sleep_wakeup_count', + 'unit_of_measurement': 'times', + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Wakeup count', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': 'times', }), @@ -838,12 +2830,44 @@ 'state': '1', }) # --- -# name: test_all_entities[sensor.henk_wakeup_time] +# name: test_all_entities[sensor.henk_wakeup_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_wakeup_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wakeup time', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wakeup_time', + 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Wakeup time', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': , }), @@ -854,7 +2878,43 @@ 'state': '3060', }) # --- -# name: test_all_entities[sensor.henk_weight] +# name: test_all_entities[sensor.henk_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_weight_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_weight-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -869,7 +2929,40 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_weight_goal] +# name: test_all_entities[sensor.henk_weight_goal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_weight_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weight_goal', + 'unique_id': 'withings_12345_weight_goal', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_weight_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py index 227f65473fc..014beb7a233 100644 --- a/tests/components/withings/test_calendar.py +++ b/tests/components/withings/test_calendar.py @@ -58,7 +58,6 @@ async def test_api_events( async def test_calendar_created_when_workouts_available( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 5d42ace495b..88018d54877 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -30,25 +30,24 @@ async def test_all_entities( snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" with patch("homeassistant.components.withings.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, polling_config_entry) - entity_registry = er.async_get(hass) - entity_entries = er.async_entries_for_config_entry( - entity_registry, polling_config_entry.entry_id - ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, polling_config_entry.entry_id + ) - assert entity_entries - for entity_entry in entity_entries: - assert hass.states.get(entity_entry.entity_id) == snapshot( - name=entity_entry.entity_id - ) + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_update_failed( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, @@ -68,7 +67,6 @@ async def test_update_failed( async def test_update_updates_incrementally( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, @@ -253,7 +251,6 @@ async def test_sleep_sensors_created_when_existed( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test sleep sensors will be added if they existed before.""" await setup_integration(hass, polling_config_entry, False) @@ -301,7 +298,6 @@ async def test_workout_sensors_created_when_existed( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test workout sensors will be added if they existed before.""" await setup_integration(hass, polling_config_entry, False) diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr index 6fc9b2497b5..03d1d4f61dc 100644 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -38,7 +38,7 @@ 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'firmware', 'unique_id': 'aabbccddeeff_update', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 47dafe039b2..7c05390a04e 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -2,7 +2,7 @@ # name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'friendly_name': 'WLED RGB Light Segment 1 intensity', 'max': 255, 'min': 0, 'mode': , @@ -42,11 +42,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Segment 1 Intensity', + 'original_name': 'Segment 1 intensity', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_intensity', 'unique_id': 'aabbccddeeff_intensity_1', 'unit_of_measurement': None, }) @@ -86,8 +86,7 @@ # name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'WLED RGB Light Segment 1 Speed', - 'icon': 'mdi:speedometer', + 'friendly_name': 'WLED RGB Light Segment 1 speed', 'max': 255, 'min': 0, 'mode': , @@ -126,12 +125,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:speedometer', - 'original_name': 'Segment 1 Speed', + 'original_icon': None, + 'original_name': 'Segment 1 speed', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_speed', 'unique_id': 'aabbccddeeff_speed_1', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 92604f86d2d..3c96e063738 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Live override', - 'icon': 'mdi:theater', 'options': list([ '0', '1', @@ -44,7 +43,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:theater', + 'original_icon': None, 'original_name': 'Live override', 'platform': 'wled', 'previous_unique_id': None, @@ -90,7 +89,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 color palette', - 'icon': 'mdi:palette-outline', 'options': list([ 'Analogous', 'April Night', @@ -225,12 +223,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:palette-outline', + 'original_icon': None, 'original_name': 'Segment 1 color palette', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_color_palette', 'unique_id': 'aabbccddeeff_palette_1', 'unit_of_measurement': None, }) @@ -271,7 +269,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGBW Light Playlist', - 'icon': 'mdi:play-speed', 'options': list([ 'Playlist 1', 'Playlist 2', @@ -310,7 +307,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:play-speed', + 'original_icon': None, 'original_name': 'Playlist', 'platform': 'wled', 'previous_unique_id': None, @@ -356,7 +353,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGBW Light Preset', - 'icon': 'mdi:playlist-play', 'options': list([ 'Preset 1', 'Preset 2', @@ -395,7 +391,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:playlist-play', + 'original_icon': None, 'original_name': 'Preset', 'platform': 'wled', 'previous_unique_id': None, diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index feecfd1e1ff..1184f1842ac 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -5,7 +5,6 @@ 'duration': 60, 'fade': True, 'friendly_name': 'WLED RGB Light Nightlight', - 'icon': 'mdi:weather-night', 'target_brightness': 0, }), 'context': , @@ -36,7 +35,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-night', + 'original_icon': None, 'original_name': 'Nightlight', 'platform': 'wled', 'previous_unique_id': None, @@ -82,7 +81,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Reverse', - 'icon': 'mdi:swap-horizontal-bold', }), 'context': , 'entity_id': 'switch.wled_rgb_light_reverse', @@ -112,12 +110,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:swap-horizontal-bold', + 'original_icon': None, 'original_name': 'Reverse', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'reverse', 'unique_id': 'aabbccddeeff_reverse_0', 'unit_of_measurement': None, }) @@ -158,7 +156,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Sync receive', - 'icon': 'mdi:download-network-outline', 'udp_port': 21324, }), 'context': , @@ -189,7 +186,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:download-network-outline', + 'original_icon': None, 'original_name': 'Sync receive', 'platform': 'wled', 'previous_unique_id': None, @@ -235,7 +232,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Sync send', - 'icon': 'mdi:upload-network-outline', 'udp_port': 21324, }), 'context': , @@ -266,7 +262,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:upload-network-outline', + 'original_icon': None, 'original_name': 'Sync send', 'platform': 'wled', 'previous_unique_id': None, diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 2594c228eda..fc1d5503c07 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -43,7 +43,7 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Solid" assert state.attributes.get(ATTR_HS_COLOR) == (37.412, 100.0) - assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_ICON) is None assert state.state == STATE_ON assert (entry := entity_registry.async_get("light.wled_rgb_light")) @@ -54,7 +54,7 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Blink" assert state.attributes.get(ATTR_HS_COLOR) == (148.941, 100.0) - assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_ICON) is None assert state.state == STATE_ON assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index d9168d7b697..db68bc2e454 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -61,7 +61,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_free_memory")) - assert state.attributes.get(ATTR_ICON) == "mdi:memory" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.BYTES assert state.state == "14600" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -71,7 +71,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_signal")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "76" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -93,7 +93,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_channel")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "11" @@ -102,7 +102,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_bssid")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "AA:AA:AA:AA:AA:BB" @@ -111,7 +111,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_ip")) - assert state.attributes.get(ATTR_ICON) == "mdi:ip-network" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "127.0.0.1" diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index fb436a57e5c..a7e26765643 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -1,4 +1,5 @@ """Tests the Home Assistant workday binary sensor.""" + from __future__ import annotations from typing import Any @@ -181,6 +182,16 @@ TEST_CONFIG_REMOVE_NAMED = { "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], "language": "en_US", } +TEST_CONFIG_REMOVE_DATE = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2024-02-05", "2024-02-06"], + "language": "en_US", +} TEST_CONFIG_TOMORROW = { "name": DEFAULT_NAME, "country": "DE", diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index fc7bfeb1b0e..60a55e1a347 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -1,4 +1,5 @@ """Test repairs for unifiprotect.""" + from __future__ import annotations from http import HTTPStatus @@ -10,12 +11,13 @@ from homeassistant.components.repairs.websocket_api import ( 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 +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import ( TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_REMOVE_DATE, TEST_CONFIG_REMOVE_NAMED, init_integration, ) @@ -329,6 +331,7 @@ async def test_bad_named_holiday( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, ) -> None: """Test fixing bad province selecting none.""" assert await async_setup_component(hass, "repairs", {}) @@ -337,6 +340,11 @@ async def test_bad_named_holiday( state = hass.states.get("binary_sensor.workday_sensor") assert state + issues = issue_registry.issues.keys() + for issue in issues: + if issue[0] == DOMAIN: + assert issue[1].startswith("bad_named") + ws_client = await hass_ws_client(hass) client = await hass_client() @@ -365,7 +373,7 @@ async def test_bad_named_holiday( CONF_REMOVE_HOLIDAYS: "Not a Holiday", "title": entry.title, } - assert data["step_id"] == "named_holiday" + assert data["step_id"] == "fix_remove_holiday" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post( @@ -402,6 +410,81 @@ async def test_bad_named_holiday( assert not issue +async def test_bad_date_holiday( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_REMOVE_DATE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + issues = issue_registry.issues.keys() + for issue in issues: + if issue[0] == DOMAIN: + assert issue[1].startswith("bad_date") + + 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_date_holiday-1-2024_02_05": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post( + url, + json={"handler": DOMAIN, "issue_id": "bad_date_holiday-1-2024_02_05"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "US", + CONF_REMOVE_HOLIDAYS: "2024-02-05", + "title": entry.title, + } + assert data["step_id"] == "fix_remove_holiday" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"remove_holidays": ["2024-02-06"]}) + 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_date_holiday-1-2024_02_05": + issue = i + assert not issue + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_date_holiday-1-2024_02_06": + issue = i + assert issue + + async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -428,7 +511,7 @@ async def test_other_fixable_issues( "severity": "error", "translation_key": "issue_1", } - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 268ebef1d06..2adc9a21b6f 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -35,8 +35,10 @@ STT_INFO = Info( installed=True, attribution=TEST_ATTR, languages=["en-US"], + version=None, ) ], + version=None, ) ] ) @@ -55,8 +57,10 @@ TTS_INFO = Info( attribution=TEST_ATTR, languages=["en-US"], speakers=[TtsVoiceSpeaker(name="Test Speaker")], + version=None, ) ], + version=None, ) ] ) @@ -74,8 +78,10 @@ WAKE_WORD_INFO = Info( installed=True, attribution=TEST_ATTR, languages=["en-US"], + version=None, ) ], + version=None, ) ] ) @@ -86,6 +92,7 @@ SATELLITE_INFO = Info( installed=True, attribution=TEST_ATTR, area="Office", + version=None, ) ) EMPTY_INFO = Info() diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 07a6aa8925e..b6564afcfe9 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import io +from typing import Any from unittest.mock import patch import wave @@ -10,6 +12,7 @@ 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.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.tts import Synthesize @@ -100,6 +103,12 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.error_event = asyncio.Event() self.error: Error | None = None + self.pong_event = asyncio.Event() + self.pong: Pong | None = None + + self.ping_event = asyncio.Event() + self.ping: Ping | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -142,6 +151,12 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): elif Error.is_type(event.type): self.error = Error.from_event(event) self.error_event.set() + elif Pong.is_type(event.type): + self.pong = Pong.from_event(event) + self.pong_event.set() + elif Ping.is_type(event.type): + self.ping = Ping.from_event(event) + self.ping_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -150,6 +165,10 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): # Keep sending audio chunks instead of None return event or self._mic_audio_chunk + def inject_event(self, event: Event) -> None: + """Put an event in as the next response.""" + self.responses = [event] + self.responses + async def test_satellite_pipeline(hass: HomeAssistant) -> None: """Test running a pipeline with a satellite.""" @@ -157,10 +176,37 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: events = [ RunPipeline( - start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, ).event(), ] + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + audio_chunk_received = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for chunk in stt_stream: + if chunk: + audio_chunk_received.set() + break + with patch( "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, @@ -169,10 +215,11 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: SatelliteAsyncTcpClient(events), ) as mock_client, patch( "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", - ) as mock_run_pipeline, patch( + async_pipeline_from_audio_stream, + ), patch( "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", return_value=("wav", get_test_wav()), - ): + ), patch("homeassistant.components.wyoming.satellite._PING_SEND_DELAY", 0): entry = await setup_config_entry(hass) device: SatelliteDevice = hass.data[wyoming.DOMAIN][ entry.entry_id @@ -182,12 +229,39 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: 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 + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + # Test a ping + mock_client.inject_event(Ping("test-ping").event()) + + # Pong is expected with the same text + async with asyncio.timeout(1): + await mock_client.pong_event.wait() + + assert mock_client.pong is not None + assert mock_client.pong.text == "test-ping" + + # The client should have received the first ping + async with asyncio.timeout(1): + await mock_client.ping_event.wait() + + assert mock_client.ping is not None + + # Reset and send a pong back. + # We will get a second ping by the end of the test. + mock_client.ping_event.clear() + mock_client.ping = None + mock_client.inject_event(Pong().event()) # Start detecting wake word - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.WAKE_WORD_START ) @@ -198,8 +272,13 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert not device.is_active assert not device.is_muted + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + # Wake word is detected - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.WAKE_WORD_END, {"wake_word_output": {"wake_word_id": "test_wake_word"}}, @@ -215,7 +294,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert device.is_active # Speech-to-text started - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_START, {"metadata": {"language": "en"}}, @@ -227,8 +306,13 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.transcribe is not None assert mock_client.transcribe.language == "en" + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + # User started speaking - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} ) @@ -240,7 +324,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.voice_started.timestamp == 1234 # User stopped speaking - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} ) @@ -252,7 +336,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.voice_stopped.timestamp == 5678 # Speech-to-text transcription - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_END, {"stt_output": {"text": "test transcript"}}, @@ -265,7 +349,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.transcript.text == "test transcript" # Text-to-speech text - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_START, { @@ -283,7 +367,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.synthesize.voice.name == "test voice" # Text-to-speech media - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, {"tts_output": {"media_id": "test media id"}}, @@ -302,11 +386,21 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.tts_audio_chunk.audio == b"123" # Pipeline finished - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) ) assert not device.is_active + # The client should have received another ping by now + async with asyncio.timeout(1): + await mock_client.ping_event.wait() + + assert mock_client.ping is not None + + # Pipeline should automatically restart + async with asyncio.timeout(1): + await run_pipeline_called.wait() + # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -317,6 +411,7 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: on_muted_event = asyncio.Event() original_make_satellite = wyoming._make_satellite + original_on_muted = wyoming.satellite.WyomingSatellite.on_muted def make_muted_satellite( hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService @@ -327,6 +422,14 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: return satellite async def on_muted(self): + # Trigger original function + self._muted_changed_event.set() + await original_on_muted(self) + + # Ensure satellite stops + self.is_running = False + + # Proceed with test self.device.set_is_muted(False) on_muted_event.set() @@ -339,16 +442,23 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", on_muted, ): - await setup_config_entry(hass) + entry = await setup_config_entry(hass) async with asyncio.timeout(1): await on_muted_event.wait() + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + async def test_satellite_restart(hass: HomeAssistant) -> None: """Test pipeline loop restart after unexpected error.""" on_restart_event = asyncio.Event() + original_on_restart = wyoming.satellite.WyomingSatellite.on_restart + async def on_restart(self): + await original_on_restart(self) self.stop() on_restart_event.set() @@ -356,12 +466,12 @@ async def test_satellite_restart(hass: HomeAssistant) -> None: "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, ), patch( - "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + "homeassistant.components.wyoming.satellite.WyomingSatellite._connect_and_loop", side_effect=RuntimeError(), ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", on_restart, - ): + ), patch("homeassistant.components.wyoming.satellite._RESTART_SECONDS", 0): await setup_config_entry(hass) async with asyncio.timeout(1): await on_restart_event.wait() @@ -373,7 +483,11 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None: reconnect_event = asyncio.Event() stopped_event = asyncio.Event() + original_on_reconnect = wyoming.satellite.WyomingSatellite.on_reconnect + async def on_reconnect(self): + await original_on_reconnect(self) + nonlocal num_reconnects num_reconnects += 1 if num_reconnects >= 2: @@ -395,7 +509,7 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", on_stopped, - ): + ), patch("homeassistant.components.wyoming.satellite._RECONNECT_SECONDS", 0): await setup_config_entry(hass) async with asyncio.timeout(1): await reconnect_event.wait() @@ -519,3 +633,338 @@ async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: assert mock_client.error is not None assert mock_client.error.text == "test message" assert mock_client.error.code == "test code" + + +async def test_tts_not_wav(hass: HomeAssistant) -> None: + """Test satellite receiving non-WAV audio from text-to-speech.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + original_stream_tts = wyoming.satellite.WyomingSatellite._stream_tts + error_event = asyncio.Event() + + async def _stream_tts(self, media_id): + try: + await original_stream_tts(self, media_id) + except ValueError: + error_event.set() + + events = [ + RunPipeline(start_stage=PipelineStage.TTS, 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=("mp3", bytes(1)), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._stream_tts", + _stream_tts, + ): + entry = 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"] + + # 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() + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + + # Expect error because only WAV is supported + async with asyncio.timeout(1): + await error_event.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_pipeline_changed(hass: HomeAssistant) -> None: + """Test that changing the pipeline setting stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + 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", + async_pipeline_from_audio_stream, + ): + 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() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Change pipelines + device.set_pipeline_name("different pipeline") + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_audio_settings_changed(hass: HomeAssistant) -> None: + """Test that changing audio settings stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + 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", + async_pipeline_from_audio_stream, + ): + 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() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Change audio setting + device.set_noise_suppression_level(1) + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_invalid_stages(hass: HomeAssistant) -> None: + """Test error when providing invalid pipeline stages.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + original_run_pipeline_once = wyoming.satellite.WyomingSatellite._run_pipeline_once + start_stage_event = asyncio.Event() + end_stage_event = asyncio.Event() + + def _run_pipeline_once(self, run_pipeline): + # Set bad start stage + run_pipeline.start_stage = PipelineStage.INTENT + run_pipeline.end_stage = PipelineStage.TTS + + try: + original_run_pipeline_once(self, run_pipeline) + except ValueError: + start_stage_event.set() + + # Set bad end stage + run_pipeline.start_stage = PipelineStage.WAKE + run_pipeline.end_stage = PipelineStage.INTENT + + try: + original_run_pipeline_once(self, run_pipeline) + except ValueError: + end_stage_event.set() + + 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.WyomingSatellite._run_pipeline_once", + _run_pipeline_once, + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + async with asyncio.timeout(1): + await start_stage_event.wait() + await end_stage_event.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_client_stops_pipeline(hass: HomeAssistant) -> None: + """Test that an AudioStop message stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + 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", + async_pipeline_from_audio_stream, + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Client sends stop message + mock_client.inject_event(AudioStop().event()) + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index 36a6daf0452..1ab869b1b0a 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -188,6 +188,7 @@ async def test_dynamic_wake_word_info( installed=True, attribution=TEST_ATTR, languages=[], + version=None, ), WakeModel( name="ww2", @@ -195,8 +196,10 @@ async def test_dynamic_wake_word_info( installed=True, attribution=TEST_ATTR, languages=[], + version=None, ), ], + version=None, ) ] ) diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index eba850e61e9..31f896680bf 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -4,27 +4,19 @@ import pytest from homeassistant.components import automation from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.xiaomi_ble.const import ( - CONF_EVENT_PROPERTIES, - DOMAIN, - EVENT_PROPERTIES, - EVENT_TYPE, - XIAOMI_BLE_EVENT, -) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_PLATFORM, - CONF_TYPE, -) +from homeassistant.components.xiaomi_ble.const import CONF_SUBTYPE, DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component from . import make_advertisement from tests.common import ( + Any, MockConfigEntry, async_capture_events, async_get_device_automations, @@ -45,11 +37,8 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def _async_setup_xiaomi_device(hass, mac: str): - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=mac, - ) +async def _async_setup_xiaomi_device(hass, mac: str, data: Any | None = None): + config_entry = MockConfigEntry(domain=DOMAIN, unique_id=mac, data=data) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -58,6 +47,33 @@ async def _async_setup_xiaomi_device(hass, mac: str): return config_entry +async def test_event_button_press(hass: HomeAssistant) -> None: + """Make sure that a button press event is fired.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["address"] == "54:EF:44:E3:9C:BC" + assert events[0].data["event_type"] == "press" + assert events[0].data["event_properties"] is None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_event_motion_detected(hass: HomeAssistant) -> None: """Make sure that a motion detected event is fired.""" mac = "DE:70:E8:B2:39:0C" @@ -81,9 +97,87 @@ async def test_event_motion_detected(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: +async def test_get_triggers_button(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a Xiaomi BLE button sensor.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button", + CONF_SUBTYPE: "press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_double_button(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a Xiaomi BLE switch with 2 buttons.""" + mac = "DC:ED:83:87:12:73" + data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Os\x12\x87\x83\xed\xdc\x0b48\n\x02\x00\x00\x8dI\xae(", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button_right", + CONF_SUBTYPE: "long_press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_motion(hass: HomeAssistant) -> None: """Test that we get the expected triggers from a Xiaomi BLE motion sensor.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -99,14 +193,15 @@ async def test_get_triggers( await hass.async_block_till_done() assert len(events) == 1 - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device.id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", "metadata": {}, } triggers = await async_get_device_automations( @@ -118,25 +213,24 @@ async def test_get_triggers( await hass.async_block_till_done() -async def test_get_triggers_for_invalid_xiami_ble_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we don't get triggers for an invalid device.""" - mac = "DE:70:E8:B2:39:0C" +async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> None: + """Test that we don't get triggers for an device that does not emit events.""" + mac = "C4:7C:8D:6A:3E:7A" entry = await _async_setup_xiaomi_device(hass, mac) events = async_capture_events(hass, "xiaomi_ble_event") - # Emit motion detected event so it creates the device in the registry + # Creates the device in the registry but no events inject_bluetooth_service_info_bleak( hass, - make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + make_advertisement(mac, b"q \x5d\x01iz>j\x8d|\xc4\r\x10\x10\x02\xf4\x00"), ) - # wait for the event + # wait to make sure there are no events await hass.async_block_till_done() - assert len(events) == 1 + assert len(events) == 0 - invalid_device = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + invalid_device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -150,9 +244,7 @@ async def test_get_triggers_for_invalid_xiami_ble_device( await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: +async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: """Test that we don't get triggers when using an invalid device_id.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -166,9 +258,11 @@ async def test_get_triggers_for_invalid_device_id( # wait for the event await hass.async_block_till_done() - invalid_device = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + + invalid_device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -180,23 +274,26 @@ async def test_get_triggers_for_invalid_device_id( await hass.async_block_till_done() -async def test_if_fires_on_motion_detected( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry -) -> None: - """Test for motion event trigger firing.""" - mac = "DE:70:E8:B2:39:0C" - entry = await _async_setup_xiaomi_device(hass, mac) +async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: + """Test for button press event trigger firing.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) - # Emit motion detected event so it creates the device in the registry + # Creates the device in the registry inject_bluetooth_service_info_bleak( hass, - make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + make_advertisement( + mac, + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + ), ) - # wait for the event + # wait for the device being created await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -209,8 +306,124 @@ async def test_if_fires_on_motion_detected( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "button", + CONF_SUBTYPE: "press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_button_press"}, + }, + }, + ] + }, + ) + # Emit button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_button_press" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_if_fires_on_double_button_long_press(hass: HomeAssistant, calls) -> None: + """Test for button press event trigger firing.""" + mac = "DC:ED:83:87:12:73" + data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + + # Emit left button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Ks\x12\x87\x83\xed\xdc!\xad\xb4\xcd\x02\x00\x00,\xf3\xd9\x83", + ), + ) + + # wait for the device being created + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device_id = device.id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "button_right", + CONF_SUBTYPE: "press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_right_button_press"}, + }, + }, + ] + }, + ) + # Emit right button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Ps\x12\x87\x83\xed\xdc\x13~~\xbe\x02\x00\x00\xf0\\;4", + ), + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_right_button_press" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: + """Test for motion event trigger firing.""" + mac = "DE:70:E8:B2:39:0C" + entry = await _async_setup_xiaomi_device(hass, mac) + + # Creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement(mac, b"@0\xdd\x03$\x0A\x10\x01\x64"), + ) + + # wait for the device being created + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device_id = device.id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", }, "action": { "service": "test.automation", @@ -220,15 +433,11 @@ async def test_if_fires_on_motion_detected( ] }, ) - - message = { - CONF_DEVICE_ID: device_id, - CONF_ADDRESS: "DE:70:E8:B2:39:0C", - EVENT_TYPE: "motion_detected", - EVENT_PROPERTIES: None, - } - - hass.bus.async_fire(XIAOMI_BLE_EVENT, message) + # Emit motion detected event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -241,7 +450,6 @@ async def test_if_fires_on_motion_detected( async def test_automation_with_invalid_trigger_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger type.""" mac = "DE:70:E8:B2:39:0C" @@ -256,7 +464,8 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -270,7 +479,7 @@ async def test_automation_with_invalid_trigger_type( CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, CONF_TYPE: "invalid", - CONF_EVENT_PROPERTIES: None, + CONF_SUBTYPE: None, }, "action": { "service": "test.automation", @@ -290,7 +499,6 @@ async def test_automation_with_invalid_trigger_type( async def test_automation_with_invalid_trigger_event_property( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger event property.""" mac = "DE:70:E8:B2:39:0C" @@ -305,7 +513,8 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -318,27 +527,28 @@ async def test_automation_with_invalid_trigger_event_property( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: "invalid_property", + CONF_TYPE: "motion", + CONF_SUBTYPE: "invalid_subtype", }, "action": { "service": "test.automation", - "data_template": {"some": "test_trigger_motion_detected"}, + "data_template": { + "some": "test_trigger_motion_motion_detected" + }, }, }, ] }, ) - # Logs should return message to make sure event property is of one [None] for motion event - assert str([None]) in caplog.text + await hass.async_block_till_done() + # Logs should return message to make sure subtype is of one 'motion_detected' for motion event + assert "value must be one of ['motion_detected']" in caplog.text assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() -async def test_triggers_for_invalid__model( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry -) -> None: +async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: """Test invalid model doesn't return triggers.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -353,7 +563,8 @@ async def test_triggers_for_invalid__model( await hass.async_block_till_done() # modify model to invalid model - invalid_model = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + invalid_model = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, mac)}, model="invalid model", @@ -371,12 +582,14 @@ async def test_triggers_for_invalid__model( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: invalid_model_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", }, "action": { "service": "test.automation", - "data_template": {"some": "test_trigger_motion_detected"}, + "data_template": { + "some": "test_trigger_motion_motion_detected" + }, }, }, ] diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py new file mode 100644 index 00000000000..1d2cf5fb3fc --- /dev/null +++ b/tests/components/xiaomi_ble/test_event.py @@ -0,0 +1,126 @@ +"""Test the Xiaomi BLE events.""" +import pytest + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.xiaomi_ble.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import make_advertisement + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + BluetoothServiceInfoBleak, + inject_bluetooth_service_info, +) + + +@pytest.mark.parametrize( + ("mac_address", "advertisement", "bind_key", "result"), + [ + ( + "54:EF:44:E3:9C:BC", + make_advertisement( + "54:EF:44:E3:9C:BC", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' + b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + "5b51a7c91cde6707c9ef18dfda143a58", + [ + { + "entity": "event.smoke_detector_9cbc_button", + ATTR_FRIENDLY_NAME: "Smoke Detector 9CBC Button", + ATTR_EVENT_TYPE: "press", + } + ], + ), + ( + "DC:ED:83:87:12:73", + make_advertisement( + "DC:ED:83:87:12:73", + b"XYI\x19Os\x12\x87\x83\xed\xdc\x0b48\n\x02\x00\x00\x8dI\xae(", + ), + "b93eb3787eabda352edd94b667f5d5a9", + [ + { + "entity": "event.switch_double_button_1273_button_right", + ATTR_FRIENDLY_NAME: "Switch (double button) 1273 Button right", + ATTR_EVENT_TYPE: "press", + } + ], + ), + ( + "DE:70:E8:B2:39:0C", + make_advertisement( + "DE:70:E8:B2:39:0C", + b"@0\xdd\x03$\x03\x00\x01\x01", + ), + None, + [ + { + "entity": "event.nightlight_390c_motion", + ATTR_FRIENDLY_NAME: "Nightlight 390C Motion", + ATTR_EVENT_TYPE: "motion_detected", + } + ], + ), + ], +) +async def test_events( + hass: HomeAssistant, + mac_address: str, + advertisement: BluetoothServiceInfoBleak, + bind_key: str | None, + result: list[dict[str, str]], +) -> None: + """Test the different Xiaomi BLE events.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Ensure entities are restored + for meas in result: + state = hass.states.get(meas["entity"]) + assert state != STATE_UNAVAILABLE + + # Now inject again + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 593b9a7a9d0..a8ce68a3209 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -958,7 +958,7 @@ async def test_reauth(hass: HomeAssistant) -> None: result2["flow_id"], { CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", - CONF_SLOT: 66, + CONF_SLOT: 67, }, ) await hass.async_block_till_done() diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 441ec202b28..da907fdee33 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -826,6 +826,9 @@ async def test_device_types( # nightlight as a setting of the main entity if nightlight_mode_properties is not None: mocked_bulb.last_properties["active_mode"] = True + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) config_entry.add_to_hass(hass) await _async_setup(config_entry) state = hass.states.get(entity_id) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 44155d741b7..d679ac5cb03 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -54,7 +54,6 @@ def patch_cluster(cluster): cluster.configure_reporting_multiple = AsyncMock( return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0] ) - cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) cluster.read_attributes_raw = AsyncMock(side_effect=_read_attribute_raw) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a30c6f35052..1627ced5cbb 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,6 +25,7 @@ import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.helpers import restore_state from homeassistant.setup import async_setup_component @@ -153,6 +154,14 @@ async def zigpy_app_controller(): zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, zigpy.config.CONF_NWK_BACKUP_ENABLED: False, zigpy.config.CONF_TOPO_SCAN_ENABLED: False, + zigpy.config.CONF_OTA: { + zigpy.config.CONF_OTA_IKEA: False, + zigpy.config.CONF_OTA_INOVELLI: False, + zigpy.config.CONF_OTA_LEDVANCE: False, + zigpy.config.CONF_OTA_SALUS: False, + zigpy.config.CONF_OTA_SONOFF: False, + zigpy.config.CONF_OTA_THIRDREALITY: False, + }, } ) @@ -381,7 +390,7 @@ def zha_device_joined_restored(request): @pytest.fixture def zha_device_mock( - hass, zigpy_device_mock + hass, config_entry, zigpy_device_mock ) -> Callable[..., zha_core_device.ZHADevice]: """Return a ZHA Device factory.""" @@ -409,7 +418,11 @@ def zha_device_mock( zigpy_device = zigpy_device_mock( endpoints, ieee, manufacturer, model, node_desc, patch_cluster=patch_cluster ) - zha_device = zha_core_device.ZHADevice(hass, zigpy_device, MagicMock()) + zha_device = zha_core_device.ZHADevice( + hass, + zigpy_device, + ZHAGateway(hass, {}, config_entry), + ) return zha_device return _zha_device diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index b693c034199..d60b4bd1a49 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -400,7 +400,9 @@ async def test_climate_hvac_action_running_state_zen( thrm_cluster = device_climate_zen.device.endpoints[1].thermostat entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen, hass) - sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_zen, hass) + sensor_entity_id = find_entity_id( + Platform.SENSOR, device_climate_zen, hass, "hvac_action" + ) state = hass.states.get(entity_id) assert ATTR_HVAC_ACTION not in state.attributes diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 46efe306b91..7c17d79fe0e 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -235,6 +235,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): "current_tier4_summ_delivered", "current_tier5_summ_delivered", "current_tier6_summ_delivered", + "current_summ_received", "status", }, ), @@ -712,7 +713,9 @@ async def test_zll_device_groups( """Test adding coordinator to ZLL groups.""" cluster = zigpy_zll_device.endpoints[1].lightlink - cluster_handler = cluster_handlers.lightlink.LightLink(cluster, endpoint) + cluster_handler = cluster_handlers.lightlink.LightLinkClusterHandler( + cluster, endpoint + ) get_group_identifiers_rsp = zigpy.zcl.clusters.lightlink.LightLink.commands_by_name[ "get_group_identifiers_rsp" @@ -979,3 +982,17 @@ async def test_retry_request( assert func.await_count == 3 assert isinstance(exc.value, HomeAssistantError) assert str(exc.value) == expected_error + + +async def test_cluster_handler_naming() -> None: + """Test that all cluster handlers are named appropriately.""" + for client_cluster_handler in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.values(): + assert issubclass(client_cluster_handler, cluster_handlers.ClientClusterHandler) + assert client_cluster_handler.__name__.endswith("ClientClusterHandler") + + for cluster_handler_dict in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.values(): + for cluster_handler in cluster_handler_dict.values(): + assert not issubclass( + cluster_handler, cluster_handlers.ClientClusterHandler + ) + assert cluster_handler.__name__.endswith("ClusterHandler") diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 0adb7583d31..55a4cbebfe7 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -28,7 +28,9 @@ from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( ATTR_COMMAND, STATE_CLOSED, + STATE_CLOSING, STATE_OPEN, + STATE_OPENING, STATE_UNAVAILABLE, Platform, ) @@ -42,6 +44,7 @@ from .common import ( find_entity_id, make_zcl_header, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -131,21 +134,40 @@ def zigpy_keen_vent(zigpy_device_mock): ) -async def test_cover( +WCAttrs = closures.WindowCovering.AttributeDefs +WCCmds = closures.WindowCovering.ServerCommandDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus + + +async def test_cover_non_tilt_initial_state( hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device ) -> None: """Test ZHA cover platform.""" # load up cover domain - cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster = zigpy_cover_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - "current_position_lift_percentage": 65, - "current_position_tilt_percentage": 42, + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } + update_attribute_cache(cluster) zha_device = await zha_device_joined_restored(zigpy_cover_device) - assert cluster.read_attributes.call_count == 1 - assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] - assert "current_position_tilt_percentage" in cluster.read_attributes.call_args[0][0] + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -161,27 +183,86 @@ async def test_cover( # test update prev_call_count = cluster.read_attributes.call_count await async_update_entity(hass, entity_id) - assert cluster.read_attributes.call_count == prev_call_count + 2 + assert cluster.read_attributes.call_count == prev_call_count + 1 state = hass.states.get(entity_id) assert state assert state.state == STATE_OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 35 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_cover( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.COVER, zha_device, hass) + assert entity_id is not None + + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the cover was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 1 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 # test that the state has changed from unavailable to off - await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) assert hass.states.get(entity_id).state == STATE_CLOSED # test to see if it opens - await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) assert hass.states.get(entity_id).state == STATE_OPEN # test that the state remains after tilting to 100% - await send_attributes_report(hass, cluster, {0: 0, 9: 100, 1: 1}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) assert hass.states.get(entity_id).state == STATE_OPEN # test to see the state remains after tilting to 0% - await send_attributes_report(hass, cluster, {0: 1, 9: 0, 1: 100}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) assert hass.states.get(entity_id).state == STATE_OPEN # close from UI @@ -192,9 +273,17 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x01 - assert cluster.request.call_args[0][2].command.name == "down_close" + assert cluster.request.call_args[0][2].command.name == WCCmds.down_close.name assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSED + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -205,10 +294,21 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 100 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSED + # open from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -217,9 +317,17 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x00 - assert cluster.request.call_args[0][2].command.name == "up_open" + assert cluster.request.call_args[0][2].command.name == WCCmds.up_open.name assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_OPENING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -230,10 +338,21 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 0 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_OPENING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + # set position UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -245,10 +364,27 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x05 - assert cluster.request.call_args[0][2].command.name == "go_to_lift_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_lift_percentage.name + ) assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -259,10 +395,27 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + # stop from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -271,7 +424,7 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x02 - assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name assert cluster.request.call_args[1]["expect_reply"] is True with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): @@ -284,11 +437,11 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x02 - assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name assert cluster.request.call_args[1]["expect_reply"] is True # test rejoin - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 0} + cluster.PLUGGED_ATTR_READS = {WCAttrs.current_position_lift_percentage.name: 0} await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,)) assert hass.states.get(entity_id).state == STATE_OPEN @@ -303,7 +456,10 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 100 assert cluster.request.call_args[1]["expect_reply"] is True @@ -314,11 +470,12 @@ async def test_cover_failures( """Test ZHA cover platform failure cases.""" # load up cover domain - cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster = zigpy_cover_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - "current_position_lift_percentage": None, - "current_position_tilt_percentage": 42, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, } + update_attribute_cache(cluster) zha_device = await zha_device_joined_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -331,7 +488,7 @@ async def test_cover_failures( # test update returned None prev_call_count = cluster.read_attributes.call_count await async_update_entity(hass, entity_id) - assert cluster.read_attributes.call_count == prev_call_count + 2 + assert cluster.read_attributes.call_count == prev_call_count + 1 assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # allow traffic to flow through the gateway and device @@ -493,6 +650,27 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.stop.id ) + # stop from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to stop cover"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.stop.id + ) + async def test_shade( hass: HomeAssistant, zha_device_joined_restored, zigpy_shade_device @@ -502,8 +680,8 @@ async def test_shade( # load up cover domain zha_device = await zha_device_joined_restored(zigpy_shade_device) - cluster_on_off = zigpy_shade_device.endpoints.get(1).on_off - cluster_level = zigpy_shade_device.endpoints.get(1).level + cluster_on_off = zigpy_shade_device.endpoints[1].on_off + cluster_level = zigpy_shade_device.endpoints[1].level entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -685,7 +863,7 @@ async def test_shade_restore_state( ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) zha_device = await zha_device_restored(zigpy_shade_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -700,18 +878,15 @@ async def test_cover_restore_state( hass: HomeAssistant, zha_device_restored, zigpy_cover_device ) -> None: """Ensure states are restored on startup.""" - mock_restore_cache( - hass, - ( - State( - "cover.fakemanufacturer_fakemodel_cover", - STATE_OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 42}, - ), - ), - ) + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 50, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + } + update_attribute_cache(cluster) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) zha_device = await zha_device_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -719,8 +894,8 @@ async def test_cover_restore_state( # test that the cover was created and that it is available assert hass.states.get(entity_id).state == STATE_OPEN - assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 - assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 42 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 100 - 50 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 100 - 42 async def test_keen_vent( @@ -731,8 +906,8 @@ async def test_keen_vent( # load up cover domain zha_device = await zha_device_joined_restored(zigpy_keen_vent) - cluster_on_off = zigpy_keen_vent.endpoints.get(1).on_off - cluster_level = zigpy_keen_vent.endpoints.get(1).level + cluster_on_off = zigpy_keen_vent.endpoints[1].on_off + cluster_level = zigpy_keen_vent.endpoints[1].level entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index f19ed9bd4a9..e117caf4325 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -365,7 +365,7 @@ async def test_startup_concurrency_limit( zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered ) - zha_gateway._async_get_or_create_device(zigpy_dev, restored=True) + zha_gateway._async_get_or_create_device(zigpy_dev) # Keep track of request concurrency during initialization current_concurrency = 0 diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index c2e9469c239..9c9ffbb3ba1 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -279,7 +279,7 @@ async def test_shutdown_on_ha_stop( zha_data.gateway, "shutdown", wraps=zha_data.gateway.shutdown ) as mock_shutdown: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert len(mock_shutdown.mock_calls) == 1 diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 0d3035f9717..4b71fd723ad 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -5,10 +5,8 @@ from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.homeautomation as homeautomation -import zigpy.zcl.clusters.measurement as measurement -import zigpy.zcl.clusters.smartenergy as smartenergy +from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy +from zigpy.zcl.clusters.hvac import Thermostat from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zha.core.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ @@ -70,7 +68,7 @@ def sensor_platform_only(): @pytest.fixture -async def elec_measurement_zigpy_dev(hass, zigpy_device_mock): +async def elec_measurement_zigpy_dev(hass: HomeAssistant, zigpy_device_mock): """Electric Measurement zigpy device.""" zigpy_device = zigpy_device_mock( @@ -110,19 +108,19 @@ async def elec_measurement_zha_dev(elec_measurement_zigpy_dev, zha_device_joined return zha_dev -async def async_test_humidity(hass, cluster, entity_id): +async def async_test_humidity(hass: HomeAssistant, cluster, entity_id): """Test humidity sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 100}) assert_state(hass, entity_id, "10.0", PERCENTAGE) -async def async_test_temperature(hass, cluster, entity_id): +async def async_test_temperature(hass: HomeAssistant, cluster, entity_id): """Test temperature sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 2900, 2: 100}) assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS) -async def async_test_pressure(hass, cluster, entity_id): +async def async_test_pressure(hass: HomeAssistant, cluster, entity_id): """Test pressure sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000}) assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) @@ -131,7 +129,7 @@ async def async_test_pressure(hass, cluster, entity_id): assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) -async def async_test_illuminance(hass, cluster, entity_id): +async def async_test_illuminance(hass: HomeAssistant, cluster, entity_id): """Test illuminance sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 10, 2: 20}) assert_state(hass, entity_id, "1", LIGHT_LUX) @@ -143,7 +141,7 @@ async def async_test_illuminance(hass, cluster, entity_id): assert_state(hass, entity_id, "unknown", LIGHT_LUX) -async def async_test_metering(hass, cluster, entity_id): +async def async_test_metering(hass: HomeAssistant, cluster, entity_id): """Test Smart Energy metering sensor.""" await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) assert_state(hass, entity_id, "12345.0", None) @@ -158,19 +156,45 @@ async def async_test_metering(hass, cluster, entity_id): ) await send_attributes_report( - hass, cluster, {"status": 32, "metering_device_type": 1} + hass, cluster, {"status": 64 + 8, "metering_device_type": 1} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|NOT_DEFINED", + "NOT_DEFINED|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + hass, cluster, {"status": 64 + 8, "metering_device_type": 2} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|PIPE_EMPTY", + "PIPE_EMPTY|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + hass, cluster, {"status": 64 + 8, "metering_device_type": 5} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|TEMPERATURE_SENSOR", + "TEMPERATURE_SENSOR|SERVICE_DISCONNECT", + ) + + # Status for other meter types + await send_attributes_report( + hass, cluster, {"status": 32, "metering_device_type": 4} ) - # currently only statuses for electric meters are supported assert hass.states.get(entity_id).attributes["status"] in ("", "32") -async def async_test_smart_energy_summation(hass, cluster, entity_id): - """Test SmartEnergy Summation delivered sensro.""" +async def async_test_smart_energy_summation_delivered( + hass: HomeAssistant, cluster, entity_id +): + """Test SmartEnergy Summation delivered sensor.""" await send_attributes_report( hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} ) - assert_state(hass, entity_id, "12.32", UnitOfVolume.CUBIC_METERS) + assert_state(hass, entity_id, "12.321", UnitOfEnergy.KILO_WATT_HOUR) assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" assert ( @@ -179,7 +203,24 @@ async def async_test_smart_energy_summation(hass, cluster, entity_id): ) -async def async_test_electrical_measurement(hass, cluster, entity_id): +async def async_test_smart_energy_summation_received( + hass: HomeAssistant, cluster, entity_id +): + """Test SmartEnergy Summation received sensor.""" + + await send_attributes_report( + hass, cluster, {1025: 1, "current_summ_received": 12321, 1026: 100} + ) + assert_state(hass, entity_id, "12.321", UnitOfEnergy.KILO_WATT_HOUR) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + assert ( + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] + == SensorDeviceClass.ENERGY + ) + + +async def async_test_electrical_measurement(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement sensor.""" # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) @@ -201,7 +242,7 @@ async def async_test_electrical_measurement(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["active_power_max"] == "8.8" -async def async_test_em_apparent_power(hass, cluster, entity_id): +async def async_test_em_apparent_power(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement Apparent Power sensor.""" # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) @@ -219,7 +260,25 @@ async def async_test_em_apparent_power(hass, cluster, entity_id): assert_state(hass, entity_id, "9.9", UnitOfApparentPower.VOLT_AMPERE) -async def async_test_em_rms_current(hass, cluster, entity_id): +async def async_test_em_power_factor(hass: HomeAssistant, cluster, entity_id): + """Test electrical measurement Power Factor sensor.""" + # update divisor cached value + await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 1000}) + assert_state(hass, entity_id, "100", PERCENTAGE) + + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 1000}) + assert_state(hass, entity_id, "99", PERCENTAGE) + + await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 5000}) + assert_state(hass, entity_id, "100", PERCENTAGE) + + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 5000}) + assert_state(hass, entity_id, "99", PERCENTAGE) + + +async def async_test_em_rms_current(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement RMS Current sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1234, 10: 1000}) @@ -237,7 +296,7 @@ async def async_test_em_rms_current(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["rms_current_max"] == "8.8" -async def async_test_em_rms_voltage(hass, cluster, entity_id): +async def async_test_em_rms_voltage(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement RMS Voltage sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0505: 1234, 10: 1000}) @@ -255,7 +314,7 @@ async def async_test_em_rms_voltage(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["rms_voltage_max"] == "8.9" -async def async_test_powerconfiguration(hass, cluster, entity_id): +async def async_test_powerconfiguration(hass: HomeAssistant, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: 98}) assert_state(hass, entity_id, "49", "%") @@ -266,7 +325,7 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 -async def async_test_powerconfiguration2(hass, cluster, entity_id): +async def async_test_powerconfiguration2(hass: HomeAssistant, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: -1}) assert_state(hass, entity_id, STATE_UNKNOWN, "%") @@ -278,12 +337,29 @@ async def async_test_powerconfiguration2(hass, cluster, entity_id): assert_state(hass, entity_id, "49", "%") -async def async_test_device_temperature(hass, cluster, entity_id): +async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id): """Test temperature sensor.""" await send_attributes_report(hass, cluster, {0: 2900}) assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS) +async def async_test_setpoint_change_source(hass, cluster, entity_id): + """Test the translation of numerical state into enum text.""" + await send_attributes_report( + hass, cluster, {Thermostat.AttributeDefs.setpoint_change_source.id: 0x01} + ) + hass_state = hass.states.get(entity_id) + assert hass_state.state == "Schedule" + + +async def async_test_pi_heating_demand(hass, cluster, entity_id): + """Test pi heating demand is correctly returned.""" + await send_attributes_report( + hass, cluster, {Thermostat.AttributeDefs.pi_heating_demand.id: 1} + ) + assert_state(hass, entity_id, "1", "%") + + @pytest.mark.parametrize( ( "cluster_id", @@ -292,6 +368,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): "report_count", "read_plug", "unsupported_attrs", + "initial_sensor_state", ), ( ( @@ -301,6 +378,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.TemperatureMeasurement.cluster_id, @@ -309,6 +387,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.PressureMeasurement.cluster_id, @@ -317,6 +396,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.IlluminanceMeasurement.cluster_id, @@ -325,12 +405,13 @@ async def async_test_device_temperature(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( smartenergy.Metering.cluster_id, "instantaneous_demand", async_test_metering, - 9, + 10, { "demand_formatting": 0xF9, "divisor": 1, @@ -338,13 +419,14 @@ async def async_test_device_temperature(hass, cluster, entity_id): "multiplier": 1, "status": 0x00, }, - {"current_summ_delivered"}, + {"current_summ_delivered", "current_summ_received"}, + STATE_UNKNOWN, ), ( smartenergy.Metering.cluster_id, "summation_delivered", - async_test_smart_energy_summation, - 9, + async_test_smart_energy_summation_delivered, + 10, { "demand_formatting": 0xF9, "divisor": 1000, @@ -352,9 +434,28 @@ async def async_test_device_temperature(hass, cluster, entity_id): "multiplier": 1, "status": 0x00, "summation_formatting": 0b1_0111_010, - "unit_of_measure": 0x01, + "unit_of_measure": 0x00, }, - {"instaneneous_demand"}, + {"instaneneous_demand", "current_summ_received"}, + STATE_UNKNOWN, + ), + ( + smartenergy.Metering.cluster_id, + "summation_received", + async_test_smart_energy_summation_received, + 10, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summation_formatting": 0b1_0111_010, + "unit_of_measure": 0x00, + "current_summ_received": 0, + }, + {"instaneneous_demand", "current_summ_delivered"}, + "0.0", ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -363,6 +464,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"apparent_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -371,6 +473,16 @@ async def async_test_device_temperature(hass, cluster, entity_id): 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"active_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "power_factor", + async_test_em_power_factor, + 7, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"active_power", "apparent_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -379,6 +491,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, {"active_power", "apparent_power", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -387,6 +500,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, {"active_power", "apparent_power", "rms_current"}, + STATE_UNKNOWN, ), ( general.PowerConfiguration.cluster_id, @@ -399,6 +513,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): "battery_quantity": 3, }, None, + STATE_UNKNOWN, ), ( general.PowerConfiguration.cluster_id, @@ -411,6 +526,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): "battery_quantity": 3, }, None, + STATE_UNKNOWN, ), ( general.DeviceTemperature.cluster_id, @@ -419,6 +535,25 @@ async def async_test_device_temperature(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, + ), + ( + hvac.Thermostat.cluster_id, + "setpoint_change_source", + async_test_setpoint_change_source, + 10, + None, + None, + STATE_UNKNOWN, + ), + ( + hvac.Thermostat.cluster_id, + "pi_heating_demand", + async_test_pi_heating_demand, + 10, + None, + None, + STATE_UNKNOWN, ), ), ) @@ -432,6 +567,7 @@ async def test_sensor( report_count, read_plug, unsupported_attrs, + initial_sensor_state, ) -> None: """Test ZHA sensor platform.""" @@ -466,8 +602,8 @@ async def test_sensor( # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) - # test that the sensor now have a state of unknown - assert hass.states.get(entity_id).state == STATE_UNKNOWN + # test that the sensor now have their correct initial state (mostly unknown) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic await test_func(hass, cluster, entity_id) @@ -476,7 +612,7 @@ async def test_sensor( await async_test_rejoin(hass, zigpy_device, [cluster], (report_count,)) -def assert_state(hass, entity_id, state, unit_of_measurement): +def assert_state(hass: HomeAssistant, entity_id, state, unit_of_measurement): """Check that the state is what is expected. This is used to ensure that the logic in each sensor class handled the @@ -488,7 +624,7 @@ def assert_state(hass, entity_id, state, unit_of_measurement): @pytest.fixture -def hass_ms(hass): +def hass_ms(hass: HomeAssistant): """Hass instance with measurement system.""" async def _hass_ms(meas_sys): @@ -720,16 +856,16 @@ async def test_electrical_measurement_init( {"instantaneous_demand", "current_summ_delivered"}, {}, { - "summation_delivered", "instantaneous_demand", + "summation_delivered", }, ), ( smartenergy.Metering.cluster_id, {}, { - "summation_delivered", "instantaneous_demand", + "summation_delivered", }, {}, ), @@ -785,26 +921,26 @@ async def test_unsupported_attributes_sensor( ( 1, 1232000, - "123.20", + "123.2", UnitOfVolume.CUBIC_METERS, ), ( 3, 2340, - "0.23", - f"100 {UnitOfVolume.CUBIC_FEET}", + "0.65", + UnitOfVolume.CUBIC_METERS, ), ( 3, 2360, - "0.24", - f"100 {UnitOfVolume.CUBIC_FEET}", + "0.68", + UnitOfVolume.CUBIC_METERS, ), ( 8, 23660, "2.37", - "kPa", + UnitOfPressure.KPA, ), ( 0, @@ -848,6 +984,18 @@ async def test_unsupported_attributes_sensor( "10.246", UnitOfEnergy.KILO_WATT_HOUR, ), + ( + 5, + 102456, + "10.25", + "IMP gal", + ), + ( + 7, + 50124, + "5.01", + UnitOfVolume.LITERS, + ), ), ) async def test_se_summation_uom( diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 0db9b7dd18e..6bfd7e051f1 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -1,5 +1,5 @@ """Test ZHA switch.""" -from unittest.mock import call, patch +from unittest.mock import AsyncMock, call, patch import pytest from zhaquirks.const import ( @@ -13,6 +13,7 @@ from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha from zigpy.quirks import CustomCluster, CustomDevice import zigpy.types as t +import zigpy.zcl.clusters.closures as closures import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster import zigpy.zcl.foundation as zcl_f @@ -23,6 +24,7 @@ from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component from .common import ( @@ -32,8 +34,9 @@ from .common import ( async_wait_for_updates, find_entity_id, send_attributes_report, + update_attribute_cache, ) -from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE ON = 1 OFF = 0 @@ -69,6 +72,24 @@ def zigpy_device(zigpy_device_mock): return zigpy_device_mock(endpoints) +@pytest.fixture +def zigpy_cover_device(zigpy_device_mock): + """Zigpy cover device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + closures.WindowCovering.cluster_id, + ], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock(endpoints) + + @pytest.fixture async def coordinator(hass, zigpy_device_mock, zha_device_joined): """Test ZHA light platform.""" @@ -136,7 +157,7 @@ async def test_switch( """Test ZHA switch platform.""" zha_device = await zha_device_joined_restored(zigpy_device) - cluster = zigpy_device.endpoints.get(1).on_off + cluster = zigpy_device.endpoints[1].on_off entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None @@ -177,6 +198,9 @@ async def test_switch( manufacturer=None, tsn=None, ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # turn off from HA with patch( @@ -196,6 +220,9 @@ async def test_switch( manufacturer=None, tsn=None, ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF await async_setup_component(hass, "homeassistant", {}) @@ -338,6 +365,20 @@ async def test_zha_group_switch_entity( ) assert hass.states.get(entity_id).state == STATE_ON + # test turn off failure case + hold_off = group_cluster_on_off.off + group_cluster_on_off.off = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE]) + # turn off via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert len(group_cluster_on_off.off.mock_calls) == 1 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + group_cluster_on_off.off = hold_off + # turn off from HA with patch( "zigpy.zcl.Cluster.request", @@ -358,6 +399,20 @@ async def test_zha_group_switch_entity( ) assert hass.states.get(entity_id).state == STATE_OFF + # test turn on failure case + hold_on = group_cluster_on_off.on + group_cluster_on_off.on = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE]) + # turn on via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert len(group_cluster_on_off.on.mock_calls) == 1 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + group_cluster_on_off.on = hold_on + # test some of the group logic to make sure we key off states correctly await send_attributes_report(hass, dev1_cluster_on_off, {0: 1}) await send_attributes_report(hass, dev2_cluster_on_off, {0: 1}) @@ -391,7 +446,7 @@ async def test_switch_configurable( """Test ZHA configurable switch platform.""" zha_device = await zha_device_joined_restored(zigpy_device_tuya) - cluster = zigpy_device_tuya.endpoints.get(1).tuya_manufacturer + cluster = zigpy_device_tuya.endpoints[1].tuya_manufacturer entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None @@ -507,3 +562,155 @@ async def test_switch_configurable( # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device_tuya, [cluster], (0,)) + + +WCAttrs = closures.WindowCovering.AttributeDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus +WCM = closures.WindowCovering.WindowCoveringMode + + +async def test_cover_inversion_switch( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + WCAttrs.window_covering_mode.name: WCM(WCM.LEDs_display_feedback), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is not None + + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the cover was created and that it is unavailable + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 1 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # test to see the state remains after tilting to 0% + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + with patch( + "zigpy.zcl.Cluster.write_attributes", return_value=[0x1, zcl_f.Status.SUCCESS] + ): + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational + | WCCS.Open_up_commands_reversed, + } + # turn on from UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + { + WCAttrs.window_covering_mode.name: WCM.Motor_direction_reversed + | WCM.LEDs_display_feedback + }, + manufacturer=None, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + cluster.write_attributes.reset_mock() + + # turn off from UI + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational, + } + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + {WCAttrs.window_covering_mode.name: WCM.LEDs_display_feedback}, + manufacturer=None, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + cluster.write_attributes.reset_mock() + + # test that sending the command again does not result in a write + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 0 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + +async def test_cover_inversion_switch_not_created( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + # entity should not be created when mode or config status aren't present + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is None diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py new file mode 100644 index 00000000000..894b5af9aba --- /dev/null +++ b/tests/components/zha/test_update.py @@ -0,0 +1,593 @@ +"""Test ZHA firmware updates.""" +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest +from zigpy.exceptions import DeliveryError +from zigpy.ota import CachedImage +import zigpy.ota.image as firmware +import zigpy.profiles.zha as zha +import zigpy.types as t +import zigpy.zcl.clusters.general as general +import zigpy.zcl.foundation as foundation + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.components.update.const import ATTR_SKIPPED_VERSION +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .common import async_enable_traffic, find_entity_id, update_attribute_cache +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + +from tests.common import mock_restore_cache_with_extra_data + + +@pytest.fixture(autouse=True) +def update_platform_only(): + """Only set up the update and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.UPDATE, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + ), + ): + yield + + +@pytest.fixture +def zigpy_device(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00" + ) + + +async def setup_test_data( + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=False, + file_not_found=False, +): + """Set up test data for the tests.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + cluster = zigpy_device.endpoints[1].out_clusters[general.Ota.cluster_id] + if not skip_attribute_plugs: + cluster.PLUGGED_ATTR_READS = { + general.Ota.AttributeDefs.current_file_version.name: installed_fw_version + } + update_attribute_cache(cluster) + + # set up firmware image + fw_image = firmware.OTAImage() + fw_image.subelements = [firmware.SubElement(tag_id=0x0000, data=b"fw_image")] + fw_header = firmware.OTAImageHeader( + file_version=fw_version, + image_type=0x90, + manufacturer_id=zigpy_device.manufacturer_id, + upgrade_file_id=firmware.OTAImageHeader.MAGIC_VALUE, + header_version=256, + header_length=56, + field_control=0, + stack_version=2, + header_string="This is a test header!", + image_size=56 + 2 + 4 + 8, + ) + fw_image.header = fw_header + fw_image.should_update = MagicMock(return_value=True) + cached_image = CachedImage(fw_image) + + cluster.endpoint.device.application.ota.get_ota_image = AsyncMock( + return_value=None if file_not_found else cached_image + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + zha_device.async_update_sw_build_id(installed_fw_version) + + return zha_device, cluster, fw_image, installed_fw_version + + +@pytest.mark.parametrize("initial_version_unknown", (False, True)) +async def test_firmware_update_notification_from_zigpy( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device, + initial_version_unknown, +) -> None: + """Test ZHA update platform - firmware update notification.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=initial_version_unknown, + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +async def test_firmware_update_notification_from_service_call( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update manual check.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + async def _async_image_notify_side_effect(*args, **kwargs): + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await async_setup_component(hass, HA_DOMAIN, {}) + cluster.image_notify = AsyncMock(side_effect=_async_image_notify_side_effect) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert cluster.image_notify.await_count == 1 + assert cluster.image_notify.call_args_list[0] == call( + payload_type=cluster.ImageNotifyCommand.PayloadType.QueryJitter, + query_jitter=100, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +def make_packet(zigpy_device, cluster, cmd_name: str, **kwargs): + """Make a zigpy packet.""" + req_hdr, req_cmd = cluster._create_request( + general=False, + command_id=cluster.commands_by_name[cmd_name].id, + schema=cluster.commands_by_name[cmd_name].schema, + disable_default_response=False, + direction=foundation.Direction.Client_to_Server, + args=(), + kwargs=kwargs, + ) + + ota_packet = t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=zigpy_device.nwk), + src_ep=1, + dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=0x0000), + dst_ep=1, + tsn=req_hdr.tsn, + profile_id=260, + cluster_id=cluster.cluster_id, + data=t.SerializableBytes(req_hdr.serialize() + req_cmd.serialize()), + lqi=255, + rssi=-30, + ) + + return ota_packet + + +async def test_firmware_update_success( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update success.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + current_file_version=fw_image.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.image_size == fw_image.header.image_size + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + file_offset=0, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.image_block_response.schema + ): + if cmd.file_offset == 0: + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.file_offset == 0 + assert cmd.image_data == fw_image.serialize()[0:40] + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + file_offset=40, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif cmd.file_offset == 40: + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.file_offset == 40 + assert cmd.image_data == fw_image.serialize()[40:70] + + # make sure the state machine gets progress reports + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert ( + attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + ) + assert attrs[ATTR_IN_PROGRESS] == 57 + assert ( + attrs[ATTR_LATEST_VERSION] + == f"0x{fw_image.header.file_version:08x}" + ) + + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.upgrade_end.name, + status=foundation.Status.SUCCESS, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + ) + ) + + elif isinstance( + cmd, general.Ota.ClientCommandDefs.upgrade_end_response.schema + ): + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.current_time == 0 + assert cmd.upgrade_time == 0 + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{fw_image.header.file_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == attrs[ATTR_INSTALLED_VERSION] + + +async def test_firmware_update_raises( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update raises.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + current_file_version=fw_image.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.image_size == fw_image.header.image_size + raise DeliveryError("failed to deliver") + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + with patch( + "zigpy.device.Device.update_firmware", + AsyncMock(side_effect=DeliveryError("failed to deliver")), + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + +async def test_firmware_update_restore_data( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +async def test_firmware_update_restore_file_not_found( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data - file not found.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device, file_not_found=True + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{installed_fw_version:08x}" + + +async def test_firmware_update_restore_version_from_state_machine( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data - file not found.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=True, + file_not_found=True, + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{installed_fw_version:08x}" diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 44006ea6ca1..bafea7e1965 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -13,6 +13,7 @@ import zigpy.backups import zigpy.profiles.zha import zigpy.types from zigpy.types.named import EUI64 +import zigpy.util import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.general import Groups import zigpy.zcl.clusters.security as security @@ -528,7 +529,7 @@ async def test_permit_ha12( assert app_controller.permit.await_count == 1 assert app_controller.permit.await_args[1]["time_s"] == duration assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 IC_TEST_PARAMS = ( @@ -538,7 +539,9 @@ IC_TEST_PARAMS = ( ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", }, zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( { @@ -546,7 +549,9 @@ IC_TEST_PARAMS = ( ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", }, zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ) @@ -566,10 +571,10 @@ async def test_permit_with_install_code( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code IC_FAIL_PARAMS = ( @@ -621,19 +626,23 @@ async def test_permit_with_install_code_fail( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 IC_QR_CODE_TEST_PARAMS = ( ( {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( { @@ -643,7 +652,22 @@ IC_QR_CODE_TEST_PARAMS = ( ) }, zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), + ), + ( + { + ATTR_QR_CODE: ( + "RB01SG" + "0D836591B3CC0010000000000000000000" + "000D6F0019107BB1" + "DLK" + "E4636CB6C41617C3E08F7325FFBFE1F9" + ) + }, + zigpy.types.EUI64.convert("00:0D:6F:00:19:10:7B:B1"), + zigpy.types.KeyData.convert("E4:63:6C:B6:C4:16:17:C3:E0:8F:73:25:FF:BF:E1:F9"), ), ) @@ -663,10 +687,10 @@ async def test_permit_with_qr_code( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) @@ -685,10 +709,10 @@ async def test_ws_permit_with_qr_code( assert msg["success"] assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code @pytest.mark.parametrize("params", IC_FAIL_PARAMS) @@ -707,7 +731,7 @@ async def test_ws_permit_with_install_code_fail( assert msg["success"] is False assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 @pytest.mark.parametrize( @@ -744,7 +768,7 @@ async def test_ws_permit_ha12( assert app_controller.permit.await_count == 1 assert app_controller.permit.await_args[1]["time_s"] == duration assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 async def test_get_network_settings( diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 65ef55c4711..4c23244c5e0 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -127,6 +127,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_lqi", }, + ("update", "00:11:22:33:44:55:66:77-5-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.bosch_isw_zpr1_wp13_firmware", + }, }, }, { @@ -165,6 +170,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3130_firmware", + }, }, }, { @@ -243,6 +253,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3210_l_firmware", + }, }, }, { @@ -291,6 +306,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_humidity", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3310_s_firmware", + }, }, }, { @@ -346,6 +366,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3315_s_firmware", + }, }, }, { @@ -401,6 +426,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3320_l_firmware", + }, }, }, { @@ -456,6 +486,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3326_l_firmware", + }, }, }, { @@ -518,6 +553,11 @@ DEVICES = [ "binary_sensor.centralite_motion_sensor_a_occupancy" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_motion_sensor_a_firmware", + }, }, }, { @@ -581,6 +621,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", }, + ("update", "00:11:22:33:44:55:66:77-4-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.climaxtechnology_psmp5_00_00_02_02tc_firmware", + }, }, }, { @@ -786,30 +831,10 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_lqi", }, - ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_tone", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_level", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe_level", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe", - }, - ("siren", "00:11:22:33:44:55:66:77-1-1282"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHASiren", - DEV_SIG_ENT_MAP_ID: "siren.heiman_smokesensor_em_siren", + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_smokesensor_em_firmware", }, }, }, @@ -849,6 +874,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_co_v16_firmware", + }, }, }, { @@ -912,6 +942,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_warningdevice_firmware", + }, }, }, { @@ -965,6 +1000,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_lqi", }, + ("update", "00:11:22:33:44:55:66:77-6-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.hivehome_com_mot003_firmware", + }, }, }, { @@ -1018,6 +1058,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_firmware", + }, }, }, { @@ -1064,6 +1109,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_firmware", + }, }, }, { @@ -1110,6 +1160,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_firmware", + }, }, }, { @@ -1156,6 +1211,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_firmware", + }, }, }, { @@ -1202,6 +1262,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_firmware", + }, }, }, { @@ -1244,6 +1309,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_control_outlet_firmware", + }, }, }, { @@ -1293,6 +1363,11 @@ DEVICES = [ "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_motion_sensor_firmware", + }, }, }, { @@ -1335,6 +1410,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_on_off_switch_firmware", + }, }, }, { @@ -1377,6 +1457,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_remote_control_firmware", + }, }, }, { @@ -1421,6 +1506,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_signal_repeater_firmware", + }, }, }, { @@ -1465,6 +1555,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_wireless_dimmer_firmware", + }, }, }, { @@ -1520,6 +1615,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45852_firmware", + }, }, }, { @@ -1575,6 +1675,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45856_firmware", + }, }, }, { @@ -1630,6 +1735,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45857_firmware", + }, }, }, { @@ -1683,6 +1793,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_610_mp_1_3_firmware", + }, }, }, { @@ -1736,6 +1851,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_2_firmware", + }, }, }, { @@ -1789,6 +1909,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_3_firmware", + }, }, }, { @@ -1834,6 +1959,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.king_of_fans_inc_hbuniversalcfremote_firmware", + }, }, }, { @@ -1872,6 +2002,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lds_zbt_cctswitch_d0001_firmware", + }, }, }, { @@ -1910,6 +2045,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_a19_rgbw_firmware", + }, }, }, { @@ -1948,6 +2088,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_flex_rgbw_firmware", + }, }, }, { @@ -1986,6 +2131,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_plug_firmware", + }, }, }, { @@ -2024,6 +2174,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_rt_rgbw_firmware", + }, }, }, { @@ -2108,6 +2263,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_summation_delivered", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_plug_maus01_firmware", + }, }, }, { @@ -2203,6 +2363,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light_2", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_relay_c2acn01_firmware", + }, }, }, { @@ -2255,6 +2420,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b186acn01_firmware", + }, }, }, { @@ -2307,6 +2477,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b286acn01_firmware", + }, }, }, { @@ -2773,6 +2948,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_86sw1_firmware", + }, }, }, { @@ -2825,6 +3005,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_cube_aqgl01_firmware", + }, }, }, { @@ -2887,6 +3072,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_humidity", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_ht_firmware", + }, }, }, { @@ -2930,6 +3120,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_opening", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_magnet_firmware", + }, }, }, { @@ -3035,6 +3230,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_motion_aq2_firmware", + }, }, }, { @@ -3085,6 +3285,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_smoke_firmware", + }, }, }, { @@ -3123,6 +3328,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_switch_firmware", + }, }, }, { @@ -3239,6 +3449,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_wleak_aq1_firmware", + }, }, }, { @@ -3309,6 +3524,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_device_temperature", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_vibration_aq1_firmware", + }, }, }, { @@ -3527,6 +3747,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_a19_rgbw_firmware", + }, }, }, { @@ -3565,6 +3790,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_dimming_switch_firmware", + }, }, }, { @@ -3603,6 +3833,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_flex_rgbw_firmware", + }, }, }, { @@ -3677,6 +3912,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_rt_tunable_white_firmware", + }, }, }, { @@ -3715,6 +3955,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_plug_01_firmware", + }, }, }, { @@ -3809,6 +4054,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_switch_4x_lightify_firmware", + }, }, }, { @@ -3859,6 +4109,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_battery", }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_rwl020_firmware", + }, }, }, { @@ -3907,6 +4162,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_button_firmware", + }, }, }, { @@ -3960,6 +4220,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_multi_firmware", + }, }, }, { @@ -4008,6 +4273,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_water_firmware", + }, }, }, { @@ -4076,6 +4346,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.securifi_ltd_unk_model_firmware", + }, }, }, { @@ -4124,6 +4399,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_dws04n_sf_firmware", + }, }, }, { @@ -4209,6 +4489,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_esw01_firmware", + }, }, }, { @@ -4262,6 +4547,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_pir04_firmware", + }, }, }, { @@ -4332,6 +4622,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_rm3250zb_firmware", + }, }, }, { @@ -4422,6 +4717,21 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1123zb_firmware", + }, }, }, { @@ -4512,6 +4822,21 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1124zb_firmware", + }, }, }, { @@ -4585,6 +4910,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_outletv4_firmware", + }, }, }, { @@ -4628,6 +4958,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_tagv4_firmware", + }, }, }, { @@ -4666,6 +5001,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss007z_firmware", + }, }, }, { @@ -4709,6 +5049,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss008z_firmware", + }, }, }, { @@ -4757,6 +5102,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.visonic_mct_340_e_firmware", + }, }, }, { @@ -4805,6 +5155,21 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.zen_within_zen_01_firmware", + }, }, }, { @@ -4874,6 +5239,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_4", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.tyzb01_ns1ndbww_ts0004_firmware", + }, }, }, { @@ -4965,6 +5335,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e11_g13_firmware", + }, }, }, { @@ -5013,6 +5388,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e12_n14_firmware", + }, }, }, { @@ -5061,6 +5441,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_z01_a19nae26_firmware", + }, }, }, { @@ -5527,6 +5912,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", DEV_SIG_ENT_MAP_ID: "select.philips_sml001_motion_sensitivity", }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_sml001_firmware", + }, }, }, ] diff --git a/tests/components/zodiac/test_config_flow.py b/tests/components/zodiac/test_config_flow.py index 18a512e0b45..4b5baefb0f2 100644 --- a/tests/components/zodiac/test_config_flow.py +++ b/tests/components/zodiac/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.zodiac.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,7 +36,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +@pytest.mark.parametrize("source", [SOURCE_USER]) async def test_single_instance_allowed( hass: HomeAssistant, source: str, @@ -52,19 +52,3 @@ async def test_single_instance_allowed( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" - - -async def test_import_flow( - hass: HomeAssistant, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "Zodiac" - assert result.get("data") == {} - assert result.get("options") == {} diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json index 68bb0f03af8..55f27b7fa5a 100644 --- a/tests/components/zwave_js/fixtures/zp3111-5_state.json +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -694,7 +694,8 @@ "commandsRX": 0, "commandsDroppedRX": 0, "commandsDroppedTX": 0, - "timeoutResponse": 0 + "timeoutResponse": 0, + "lastSeen": "2024-01-01T12:00:00+00" }, "highestSecurityClass": -1, "isControllerNode": false diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index aa20bd3bb84..bf5ad88447e 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2794,6 +2794,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] @@ -2826,6 +2827,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] @@ -2857,6 +2859,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e4550b7f961..fdbb2ef7f4c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -18,7 +18,6 @@ from homeassistant.components.climate import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, @@ -41,10 +40,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir from .common import ( - CLIMATE_AIDOO_HVAC_UNIT_ENTITY, CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, CLIMATE_FLOOR_THERMOSTAT_ENTITY, @@ -86,6 +83,8 @@ async def test_thermostat_v2( == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) client.async_send_command.reset_mock() @@ -435,7 +434,10 @@ async def test_thermostat_heatit_z_trm6( assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 5 assert state.attributes[ATTR_MAX_TEMP] == 40 @@ -516,6 +518,8 @@ async def test_thermostat_heatit_z_trm3( assert ( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 5 assert state.attributes[ATTR_MAX_TEMP] == 35 @@ -585,7 +589,10 @@ async def test_thermostat_heatit_z_trm2fx( assert state.attributes[ATTR_TEMPERATURE] == 29 assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 7 assert state.attributes[ATTR_MAX_TEMP] == 35 @@ -630,7 +637,7 @@ async def test_thermostat_srt321_hrt4_zw( HVACMode.HEAT, ] assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 384 async def test_preset_and_no_setpoint( @@ -769,98 +776,3 @@ async def test_thermostat_unknown_values( state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) assert ATTR_HVAC_ACTION not in state.attributes - - -async def test_thermostat_dry_and_fan_both_hvac_mode_and_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, -) -> None: - """Test that dry and fan modes are both available as hvac mode and preset.""" - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.HEAT, - HVACMode.COOL, - HVACMode.FAN_ONLY, - HVACMode.DRY, - HVACMode.HEAT_COOL, - ] - assert state.attributes[ATTR_PRESET_MODES] == [ - PRESET_NONE, - "Fan", - "Dry", - ] - - -async def test_thermostat_raise_repair_issue_and_warning_when_setting_dry_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test raise of repair issue and warning when setting Dry preset.""" - client.async_send_command.return_value = {"result": {"status": 1}} - - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - { - ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, - ATTR_PRESET_MODE: "Dry", - }, - blocking=True, - ) - - issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" - issue_registry = ir.async_get(hass) - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=issue_id, - ) - assert ( - "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" - in caplog.text - ) - - -async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test raise of repair issue and warning when setting Fan preset.""" - client.async_send_command.return_value = {"result": {"status": 1}} - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - { - ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, - ATTR_PRESET_MODE: "Fan", - }, - blocking=True, - ) - - issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" - issue_registry = ir.async_get(hass) - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=issue_id, - ) - assert ( - "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" - in caplog.text - ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 569e36d3b5c..67f4a8d962f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -22,9 +22,10 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) +from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory 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 async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: @@ -224,14 +225,21 @@ async def test_indicator_test( This test covers indicators that we don't already have device fixtures for. """ + device = dr.async_get(hass).async_get_device( + identifiers={get_device_id(client.driver, indicator_test)} + ) + assert device ent_reg = er.async_get(hass) - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping - assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 - assert ( - len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - ) # include node + controller status - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + entities = er.async_entries_for_device(ent_reg, device.id) + + def len_domain(domain): + return len([entity for entity in entities if entity.domain == domain]) + + assert len_domain(NUMBER_DOMAIN) == 0 + assert len_domain(BUTTON_DOMAIN) == 1 # only ping + assert len_domain(BINARY_SENSOR_DOMAIN) == 1 + assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen + assert len_domain(SWITCH_DOMAIN) == 1 entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" entry = ent_reg.async_get(entity_id) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 75a7397cc4e..4555ee59e1e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -30,6 +30,7 @@ from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import MockConfigEntry, async_get_persistent_notifications +from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") @@ -226,14 +227,16 @@ async def test_on_node_added_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 3 - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 + ent_reg = er.async_get(hass) + entities = er.async_entries_for_device(ent_reg, device.id) + # the only entities are the node status sensor, last_seen sensor, and ping button + assert len(entities) == 3 + async def test_existing_node_ready( hass: HomeAssistant, client, multisensor_6, integration @@ -328,14 +331,16 @@ async def test_existing_node_not_ready( assert not device.model assert not device.sw_version - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 3 - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 + ent_reg = er.async_get(hass) + entities = er.async_entries_for_device(ent_reg, device.id) + # the only entities are the node status sensor, last_seen sensor, and ping button + assert len(entities) == 3 + async def test_existing_node_not_replaced_when_not_ready( hass: HomeAssistant, @@ -964,22 +969,14 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 3 - # 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) == 93 - # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() - # Assert that the node and all of it's entities were removed from the device and - # entity registry + # Assert that the node was removed from the device registry 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) == 62 assert ( dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) @@ -1016,6 +1013,7 @@ async def test_node_removed( client.driver.controller.receive_event(Event("node added", event)) await hass.async_block_till_done() old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device assert old_device.id event = {"node": node, "reason": 0} @@ -1139,6 +1137,7 @@ async def test_replace_different_node( hank_binary_switch_state, client, integration, + hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" dev_reg = dr.async_get(hass) @@ -1147,11 +1146,11 @@ async def test_replace_different_node( state["nodeId"] = node_id device_id = f"{client.driver.controller.home_id}-{node_id}" - multisensor_6_device_id = ( + multisensor_6_device_id_ext = ( f"{device_id}-{multisensor_6.manufacturer_id}:" f"{multisensor_6.product_type}:{multisensor_6.product_id}" ) - hank_device_id = ( + hank_device_id_ext = ( f"{device_id}-{state['manufacturerId']}:" f"{state['productType']}:" f"{state['productId']}" @@ -1160,7 +1159,7 @@ async def test_replace_different_node( device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == dev_reg.async_get_device( - identifiers={(DOMAIN, multisensor_6_device_id)} + identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device.manufacturer == "AEON Labs" assert device.model == "ZW100" @@ -1168,8 +1167,7 @@ async def test_replace_different_node( assert hass.states.get(AIR_TEMPERATURE_SENSOR) - # A replace node event has the extra field "replaced" set to True - # to distinguish it from an exclusion + # Remove existing node event = Event( type="node removed", data={ @@ -1183,8 +1181,11 @@ async def test_replace_different_node( await hass.async_block_till_done() # Device should still be there after the node was removed - device = dev_reg.async_get(dev_id) + device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) assert device + assert len(device.identifiers) == 2 # When the node is replaced, a non-ready node added event is emitted event = Event( @@ -1238,18 +1239,164 @@ async def test_replace_different_node( client.driver.receive_event(event) await hass.async_block_till_done() - # Old device and entities were removed, but the ID is re-used - device = dev_reg.async_get(dev_id) - assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)}) - assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)}) - assert device.manufacturer == "HANK Electronics Ltd." - assert device.model == "HKZW-SO01" + # node ID based device identifier should be moved from the old multisensor device + # to the new hank device and both the old and new devices should exist. + new_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert new_device + hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert hank_device + assert hank_device == new_device + assert hank_device.identifiers == { + (DOMAIN, device_id), + (DOMAIN, hank_device_id_ext), + } + multisensor_6_device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) + assert multisensor_6_device + assert multisensor_6_device != new_device + assert multisensor_6_device.identifiers == {(DOMAIN, multisensor_6_device_id_ext)} - assert not hass.states.get(AIR_TEMPERATURE_SENSOR) + assert new_device.manufacturer == "HANK Electronics Ltd." + assert new_device.model == "HKZW-SO01" + + # We keep the old entities in case there are customizations that a user wants to + # keep. They can always delete the device and that will remove the entities as well. + assert hass.states.get(AIR_TEMPERATURE_SENSOR) assert hass.states.get("switch.smart_plug_with_two_usb_ports") + # Try to add back the first node to see if the device IDs are correct + + # Remove existing node + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "reason": 3, + "node": state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Device should still be there after the node was removed + device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert device + assert len(device.identifiers) == 2 + + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": multisensor_6.node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": False, + "interviewAttempts": 1, + "endpoints": [ + {"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None} + ], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + "isControllerNode": False, + }, + "result": {}, + }, + ) + + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Mark node as ready + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": multisensor_6_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "config", {}) + + # node ID based device identifier should be moved from the new hank device + # to the old multisensor device and both the old and new devices should exist. + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device + hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert hank_device + assert hank_device != old_device + assert hank_device.identifiers == {(DOMAIN, hank_device_id_ext)} + multisensor_6_device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) + assert multisensor_6_device + assert multisensor_6_device == old_device + assert multisensor_6_device.identifiers == { + (DOMAIN, device_id), + (DOMAIN, multisensor_6_device_id_ext), + } + + ws_client = await hass_ws_client(hass) + + # Simulate the driver not being ready to ensure that the device removal handler + # does not crash + driver = client.driver + client.driver = None + + await ws_client.send_json( + { + "id": 1, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": integration.entry_id, + "device_id": hank_device.id, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + + client.driver = driver + + # Attempting to remove the hank device should pass, but removing the multisensor should not + await ws_client.send_json( + { + "id": 2, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": integration.entry_id, + "device_id": hank_device.id, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + await ws_client.send_json( + { + "id": 3, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": integration.entry_id, + "device_id": multisensor_6_device.id, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + async def test_node_model_change( hass: HomeAssistant, zp3111, client, integration diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d18bcfa09aa..d2b702089f2 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -50,7 +50,7 @@ async def _trigger_repair_issue( return node -async def test_device_config_file_changed( +async def test_device_config_file_changed_confirm_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, @@ -58,7 +58,7 @@ async def test_device_config_file_changed( multisensor_6_state, integration, ) -> None: - """Test the device_config_file_changed issue.""" + """Test the device_config_file_changed issue confirm step.""" dev_reg = dr.async_get(hass) node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -87,16 +87,25 @@ async def test_device_config_file_changed( data = await resp.json() flow_id = data["flow_id"] - assert data["step_id"] == "confirm" + assert data["step_id"] == "init" assert data["description_placeholders"] == {"device_name": device.name} - # Apply fix url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu resp = await http_client.post(url) assert resp.status == HTTPStatus.OK data = await resp.json() + assert data["type"] == "menu" + + # Apply fix + resp = await http_client.post(url, json={"next_step_id": "confirm"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -114,6 +123,78 @@ async def test_device_config_file_changed( assert len(msg["result"]["issues"]) == 0 +async def test_device_config_file_changed_ignore_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue ignore step.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + client.async_send_command_no_wait.reset_mock() + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + assert issue["translation_placeholders"] == {"device_name": device.name} + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == {"device_name": device.name} + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "menu" + + # Ignore the issue + resp = await http_client.post(url, json={"next_step_id": "ignore"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "issue_ignored" + assert data["description_placeholders"] == {"device_name": device.name} + + await hass.async_block_till_done() + + assert len(client.async_send_command_no_wait.call_args_list) == 0 + + # Assert the issue still exists but is ignored + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert msg["result"]["issues"][0].get("dismissed_version") is not None + + async def test_invalid_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -196,14 +277,14 @@ async def test_abort_confirm( data = await resp.json() flow_id = data["flow_id"] - assert data["step_id"] == "confirm" + assert data["step_id"] == "init" # Unload config entry so we can't connect to the node await hass.config_entries.async_unload(integration.entry_id) # Apply fix url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) + resp = await http_client.post(url, json={"next_step_id": "confirm"}) assert resp.status == HTTPStatus.OK data = await resp.json() diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index f00413b0d80..a3d36b84382 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -25,7 +25,6 @@ from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -165,7 +164,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 +181,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 +189,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 @@ -308,7 +317,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "ready" - assert state.attributes[ATTR_ICON] == "mdi:check" event = Event( "status changed", @@ -318,7 +326,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "unresponsive" - assert state.attributes[ATTR_ICON] == "mdi:bell-off" # Test transitions work event = Event( @@ -329,7 +336,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "jammed" - assert state.attributes[ATTR_ICON] == "mdi:lock" # Disconnect the client and make sure the entity is still available await client.disconnect() @@ -355,33 +361,24 @@ async def test_node_status_sensor( ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "dead" - assert ( - hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:robot-dead" - ) event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "awake" - assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "asleep" - assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "alive" - assert ( - hass.states.get(node_status_entity_id).attributes[ATTR_ICON] - == "mdi:heart-pulse" - ) # Disconnect the client and make sure the entity is still available await client.disconnect() @@ -737,10 +734,10 @@ NODE_STATISTICS_SUFFIXES_UNKNOWN = { } -async def test_statistics_sensors( +async def test_statistics_sensors_no_last_seen( hass: HomeAssistant, zp3111, client, integration, caplog: pytest.LogCaptureFixture ) -> None: - """Test statistics sensors.""" + """Test all statistics sensors but last seen which is enabled by default.""" ent_reg = er.async_get(hass) for prefix, suffixes in ( @@ -846,6 +843,7 @@ async def test_statistics_sensors( "repeaterRSSI": [], "routeFailedBetween": [], }, + "lastSeen": "2024-01-01T00:00:00+0000", }, }, ) @@ -881,6 +879,22 @@ async def test_statistics_sensors( ) +async def test_last_seen_statistics_sensors( + hass: HomeAssistant, zp3111, client, integration +) -> None: + """Test last_seen statistics sensors.""" + ent_reg = er.async_get(hass) + + entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" + entry = ent_reg.async_get(entity_id) + assert entry + assert not entry.disabled + + state = hass.states.get(entity_id) + assert state + assert state.state == "2024-01-01T12:00:00+00:00" + + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { "state": 1.23, diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 9e17f25c708..ed42363ca41 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -327,14 +327,14 @@ async def test_update_entity_ha_not_running( assert len(client.async_send_command.call_args_list) == 1 # Update should be delayed by a day because HA is not running - hass.state = CoreState.starting + hass.set_state(CoreState.starting) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 - hass.state = CoreState.running + hass.set_state(CoreState.running) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index ea4ddd23d28..9e946c55831 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -517,14 +517,13 @@ def hass_fixture_setup() -> list[bool]: @pytest.fixture async def hass( hass_fixture_setup: list[bool], - event_loop: asyncio.AbstractEventLoop, load_registries: bool, hass_storage: dict[str, Any], request: pytest.FixtureRequest, ) -> AsyncGenerator[HomeAssistant, None]: """Create a test instance of Home Assistant.""" - loop = event_loop + loop = asyncio.get_running_loop() hass_fixture_setup.append(True) orig_tz = dt_util.DEFAULT_TIME_ZONE @@ -577,12 +576,11 @@ async def hass( @pytest.fixture -async def stop_hass( - event_loop: asyncio.AbstractEventLoop, -) -> AsyncGenerator[None, None]: +async def stop_hass() -> AsyncGenerator[None, None]: """Make sure all hass are stopped.""" orig_hass = ha.HomeAssistant + event_loop = asyncio.get_running_loop() created = [] def mock_hass(*args): @@ -1509,7 +1507,7 @@ async def async_setup_recorder_instance( await hass.async_block_till_done() instance = hass.data[recorder.DATA_INSTANCE] # The recorder's worker is not started until Home Assistant is running - if hass.state == CoreState.running: + if hass.state is CoreState.running: await async_recorder_block_till_done(hass) return instance @@ -1578,9 +1576,10 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # 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( - "habluetooth.scanner.OriginalBleakScanner.start", - ) as mock_bleak_scanner_start: + with patch.object( + bluetooth_scanner.OriginalBleakScanner, + "start", + ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"): yield mock_bleak_scanner_start diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index 1031134d2ad..70f86feaf79 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -11,11 +11,12 @@ '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, unit_of_measurement=None)" + "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({ @@ -30,11 +31,12 @@ '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, unit_of_measurement=None, extra='foo')" + "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({ @@ -42,17 +44,104 @@ '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..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, unit_of_measurement=None)" + "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({ @@ -67,11 +156,136 @@ '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, unit_of_measurement=None, extra='foo')" + "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({ @@ -87,11 +301,12 @@ '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, unit_of_measurement=None, extension='ext', extra='foo')" + "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({ @@ -107,11 +322,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.7 - "test_extending_entity_description..ComplexEntityDescription1(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, unit_of_measurement=None, extra='foo')" + "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({ @@ -127,9 +343,10 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.9 - "test_extending_entity_description..ComplexEntityDescription2(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, unit_of_measurement=None, extra='foo')" + "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_area_registry.py b/tests/helpers/test_area_registry.py index fd74da547d4..8a7c023ced8 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -40,7 +40,12 @@ async def test_create_area( area = area_registry.async_create("mock") assert area == ar.AreaEntry( - name="mock", normalized_name=ANY, aliases=set(), id=ANY, picture=None + aliases=set(), + icon=None, + id=ANY, + name="mock", + normalized_name=ANY, + picture=None, ) assert len(area_registry.areas) == 1 @@ -56,10 +61,11 @@ async def test_create_area( ) assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + icon=None, + id=ANY, name="mock 2", normalized_name=ANY, - aliases={"alias_1", "alias_2"}, - id=ANY, picture="/image/example.png", ) assert len(area_registry.areas) == 2 @@ -139,16 +145,18 @@ async def test_update_area( updated_area = area_registry.async_update( area.id, aliases={"alias_1", "alias_2"}, + icon="mdi:garage", name="mock1", picture="/image/example.png", ) assert updated_area != area assert updated_area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + icon="mdi:garage", + id=ANY, name="mock1", normalized_name=ANY, - aliases={"alias_1", "alias_2"}, - id=ANY, picture="/image/example.png", ) assert len(area_registry.areas) == 1 @@ -250,6 +258,7 @@ async def test_loading_area_from_storage( { "aliases": ["alias_1", "alias_2"], "id": "12345A", + "icon": "mdi:garage", "name": "mock", "picture": "blah", } @@ -287,7 +296,15 @@ async def test_migration_from_1_1( "minor_version": ar.STORAGE_VERSION_MINOR, "key": ar.STORAGE_KEY, "data": { - "areas": [{"aliases": [], "id": "12345A", "name": "mock", "picture": None}] + "areas": [ + { + "aliases": [], + "icon": None, + "id": "12345A", + "name": "mock", + "picture": None, + } + ] }, } diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 8c78b7dadc6..0d10051ca78 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -396,19 +396,19 @@ async def test_abort_discovered_multiple( HTTPStatus.UNAUTHORIZED, {}, "oauth_unauthorized", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.NOT_FOUND, {}, "oauth_failed", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.INTERNAL_SERVER_ERROR, {}, "oauth_failed", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.BAD_REQUEST, @@ -418,7 +418,7 @@ async def test_abort_discovered_multiple( "error_uri": "See the full API docs at https://authorization-server.com/docs/access_token", }, "oauth_failed", - "Token request failed (invalid_request): Request was missing the", + "Token request for oauth2_test failed (invalid_request): Request was missing the", ), ], ) @@ -541,7 +541,7 @@ async def test_abort_if_oauth_token_closing_error( with caplog.at_level(logging.DEBUG): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert "Token request failed (unknown): unknown" in caplog.text + assert "Token request for oauth2_test failed (unknown): unknown" in caplog.text assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "oauth_unauthorized" diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 240afa2cbab..c82a7493fe1 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1408,7 +1408,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( async def test_cleanup_startup(hass: HomeAssistant) -> None: """Test we run a cleanup on startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) with patch( "homeassistant.helpers.device_registry.Debouncer.async_call" diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 9f1d8dfcbc9..0b3386f8e04 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -38,7 +38,7 @@ async def test_async_create_flow_deferred_until_started( hass: HomeAssistant, mock_flow_init ) -> None: """Test flows are deferred until started.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) discovery_flow.async_create_flow( hass, "hue", @@ -79,7 +79,7 @@ async def test_async_create_flow_checks_existing_flows_before_startup( hass: HomeAssistant, mock_flow_init ) -> None: """Test existing flows prevent an identical ones from being created before startup.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for _ in range(2): discovery_flow.async_create_flow( hass, @@ -104,7 +104,7 @@ async def test_async_create_flow_does_nothing_after_stop( """Test we no longer create flows when hass is stopping.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) mock_flow_init.reset_mock() discovery_flow.async_create_flow( hass, diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index 89d23fb4533..add80c941a1 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -5,6 +5,7 @@ import pytest from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -30,6 +31,32 @@ async def test_simple_function(hass: HomeAssistant) -> None: assert calls == [3, "bla"] +async def test_signal_type(hass: HomeAssistant) -> None: + """Test dispatcher with SignalType.""" + signal: SignalType[str, int] = SignalType("test") + calls: list[tuple[str, int]] = [] + + def test_funct(data1: str, data2: int) -> None: + calls.append((data1, data2)) + + async_dispatcher_connect(hass, signal, test_funct) + async_dispatcher_send(hass, signal, "Hello", 2) + await hass.async_block_till_done() + + assert calls == [("Hello", 2)] + + async_dispatcher_send(hass, signal, "World", 3) + await hass.async_block_till_done() + + assert calls == [("Hello", 2), ("World", 3)] + + # Test compatibility with string keys + async_dispatcher_send(hass, "test", "x", 4) + await hass.async_block_till_done() + + assert calls == [("Hello", 2), ("World", 3), ("x", 4)] + + async def test_simple_function_unsub(hass: HomeAssistant) -> None: """Test simple function (executor) and unsub.""" calls1 = [] diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2e2aac570ea..19600506ae2 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -655,9 +655,7 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() - with patch( - "homeassistant.helpers.entity.CONTEXT_RECENT_TIME", timedelta(seconds=-5) - ): + with patch("homeassistant.helpers.entity.CONTEXT_RECENT_TIME_SECONDS", -5): ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" @@ -1137,6 +1135,203 @@ async def test_friendly_name_description_device_class_name( ) +@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"), ( @@ -1727,31 +1922,201 @@ def test_extending_entity_description(snapshot: SnapshotAssertion): # Try multiple direct parents @dataclasses.dataclass(frozen=True) - class MyMixin: + 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 ComplexEntityDescription1(MyMixin, entity.EntityDescription): + class ComplexEntityDescription1A(MyMixin1, entity.EntityDescription): extra: str = None - obj = ComplexEntityDescription1(key="blah", extra="foo", mixin="mixin", name="name") + obj = ComplexEntityDescription1A( + key="blah", extra="foo", mixin="mixin", name="name" + ) assert obj == snapshot - assert obj == ComplexEntityDescription1( + assert obj == ComplexEntityDescription1A( key="blah", extra="foo", mixin="mixin", name="name" ) assert repr(obj) == snapshot @dataclasses.dataclass(frozen=True, kw_only=True) - class ComplexEntityDescription2(entity.EntityDescription, MyMixin): + class ComplexEntityDescription1B(entity.EntityDescription, MyMixin1): extra: str = None - obj = ComplexEntityDescription2(key="blah", extra="foo", mixin="mixin", name="name") + obj = ComplexEntityDescription1B( + key="blah", extra="foo", mixin="mixin", name="name" + ) assert obj == snapshot - assert obj == ComplexEntityDescription2( + 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): diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index dfaec4577aa..f16b5c16b5a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import ( + area_registry as ar, device_registry as dr, entity_platform, entity_registry as er, @@ -937,7 +938,7 @@ async def test_reset_cancels_retry_setup(hass: HomeAssistant) -> None: async def test_reset_cancels_retry_setup_when_not_started(hass: HomeAssistant) -> None: """Test that resetting a platform will cancel scheduled a setup retry when not yet started.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) async_setup_entry = Mock(side_effect=PlatformNotReady) initial_listeners = hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] @@ -1628,6 +1629,87 @@ async def test_register_entity_service_response_data_multiple_matches_raises( ) +async def test_register_entity_service_limited_to_matching_platforms( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test an entity services only targets entities for the platform and domain.""" + + mock_area = area_registry.async_get_or_create("mock_area") + + entity1_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "1234", suggested_object_id="entity1" + ) + entity_registry.async_update_entity(entity1_entry.entity_id, area_id=mock_area.id) + entity2_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "5678", suggested_object_id="entity2" + ) + entity_registry.async_update_entity(entity2_entry.entity_id, area_id=mock_area.id) + entity3_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "7891", suggested_object_id="entity3" + ) + entity_registry.async_update_entity(entity3_entry.entity_id, area_id=mock_area.id) + entity4_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "1433", suggested_object_id="entity4" + ) + entity_registry.async_update_entity(entity4_entry.entity_id, area_id=mock_area.id) + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity( + entity_id=entity1_entry.entity_id, unique_id=entity1_entry.unique_id + ) + entity2 = MockEntity( + entity_id=entity2_entry.entity_id, unique_id=entity2_entry.unique_id + ) + await entity_platform.async_add_entities([entity1, entity2]) + + other_entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="other_mock_platform", platform=None + ) + entity3 = MockEntity( + entity_id=entity3_entry.entity_id, unique_id=entity3_entry.unique_id + ) + entity4 = MockEntity( + entity_id=entity4_entry.entity_id, unique_id=entity4_entry.unique_id + ) + await other_entity_platform.async_add_entities([entity3, entity4]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"area_id": [mock_area.id]}, + blocking=True, + return_response=True, + ) + # We should not target entity3 and entity4 even though they are in the area + # because they are only part of the domain and not the platform + assert response_data == { + "base_platform.entity1": { + "response-key": "response-value-base_platform.entity1" + }, + "base_platform.entity2": { + "response-key": "response-value-base_platform.entity2" + }, + } + + async def test_invalid_entity_id(hass: HomeAssistant) -> None: """Test specifying an invalid entity id.""" platform = MockEntityPlatform(hass) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index d01d7746253..1c13da1192f 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -870,7 +870,7 @@ async def test_restore_states( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test restoring states.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "light", @@ -936,7 +936,7 @@ async def test_async_get_device_class_lookup( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test registry device class lookup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "binary_sensor", diff --git a/tests/helpers/test_group.py b/tests/helpers/test_group.py new file mode 100644 index 00000000000..b1300009607 --- /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_icon.py b/tests/helpers/test_icon.py index a7fe623ea7e..cf329100d75 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -1,18 +1,26 @@ """Test Home Assistant icon util methods.""" +import pathlib +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import icon +from homeassistant.loader import IntegrationNotFound +from homeassistant.setup import async_setup_component + def test_battery_icon() -> None: """Test icon generator for battery sensor.""" - from homeassistant.helpers.icon import icon_for_battery_level + assert icon.icon_for_battery_level(None, True) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(None, False) == "mdi:battery-unknown" - assert icon_for_battery_level(None, True) == "mdi:battery-unknown" - assert icon_for_battery_level(None, False) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(5, True) == "mdi:battery-outline" + assert icon.icon_for_battery_level(5, False) == "mdi:battery-alert" - assert icon_for_battery_level(5, True) == "mdi:battery-outline" - assert icon_for_battery_level(5, False) == "mdi:battery-alert" - - assert icon_for_battery_level(100, True) == "mdi:battery-charging-100" - assert icon_for_battery_level(100, False) == "mdi:battery" + assert icon.icon_for_battery_level(100, True) == "mdi:battery-charging-100" + assert icon.icon_for_battery_level(100, False) == "mdi:battery" iconbase = "mdi:battery" for level in range(0, 100, 5): @@ -20,8 +28,8 @@ def test_battery_icon() -> None: "Level: %d. icon: %s, charging: %s" % ( level, - icon_for_battery_level(level, False), - icon_for_battery_level(level, True), + icon.icon_for_battery_level(level, False), + icon.icon_for_battery_level(level, True), ) ) if level <= 10: @@ -42,17 +50,183 @@ def test_battery_icon() -> None: postfix = "-alert" else: postfix = "" - assert iconbase + postfix == icon_for_battery_level(level, False) - assert iconbase + postfix_charging == icon_for_battery_level(level, True) + assert iconbase + postfix == icon.icon_for_battery_level(level, False) + assert iconbase + postfix_charging == icon.icon_for_battery_level(level, True) def test_signal_icon() -> None: """Test icon generator for signal sensor.""" - from homeassistant.helpers.icon import icon_for_signal_level + assert icon.icon_for_signal_level(None) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(0) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(5) == "mdi:signal-cellular-1" + assert icon.icon_for_signal_level(40) == "mdi:signal-cellular-2" + assert icon.icon_for_signal_level(80) == "mdi:signal-cellular-3" + assert icon.icon_for_signal_level(100) == "mdi:signal-cellular-3" - assert icon_for_signal_level(None) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(0) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(5) == "mdi:signal-cellular-1" - assert icon_for_signal_level(40) == "mdi:signal-cellular-2" - assert icon_for_signal_level(80) == "mdi:signal-cellular-3" - assert icon_for_signal_level(100) == "mdi:signal-cellular-3" + +def test_load_icons_files(hass: HomeAssistant) -> None: + """Test the load icons files function.""" + file1 = hass.config.path("custom_components", "test", "icons.json") + file2 = hass.config.path("custom_components", "test", "invalid.json") + assert icon._load_icons_files({"test": file1, "invalid": file2}) == { + "test": { + "entity": { + "switch": { + "something": { + "state": {"away": "mdi:home-outline", "home": "mdi:home"} + } + } + }, + }, + "invalid": {}, + } + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_icons(hass: HomeAssistant) -> None: + """Test the get icon helper.""" + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + + icons = await icon.async_get_icons(hass, "entity_component") + assert icons == {} + + # Set up test switch component + assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}}) + + # Test getting icons for the entity component + icons = await icon.async_get_icons(hass, "entity_component") + assert icons["switch"]["_"]["default"] == "mdi:toggle-switch-variant" + + # Test services icons are available + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 1 + assert icons["switch"]["turn_off"] == "mdi:toggle-switch-variant-off" + + # Ensure icons file for platform isn't loaded, as that isn't supported + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + icons = await icon.async_get_icons(hass, "entity", ["test.switch"]) + assert icons == {} + + # Load up an custom integration + hass.config.components.add("test_package") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 1 + + assert icons == { + "test_package": { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + } + + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 2 + assert icons["test_package"]["enable_god_mode"] == "mdi:shield" + + # Load another one + hass.config.components.add("test_embedded") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 2 + + assert icons["test_package"] == { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + + # Test getting non-existing integration + with pytest.raises( + IntegrationNotFound, match="Integration 'non_existing' not found" + ): + await icon.async_get_icons(hass, "entity", ["non_existing"]) + + +async def test_get_icons_while_loading_components(hass: HomeAssistant) -> None: + """Test the get icons helper loads icons.""" + integration = Mock(file_path=pathlib.Path(__file__)) + integration.name = "Component 1" + hass.config.components.add("component1") + load_count = 0 + + def mock_load_icons_files(files): + """Mock load icon files.""" + nonlocal load_count + load_count += 1 + return {"component1": {"entity": {"climate": {"test": {"icon": "mdi:home"}}}}} + + with patch( + "homeassistant.helpers.icon._component_icons_path", + return_value="choochoo.json", + ), patch( + "homeassistant.helpers.icon._load_icons_files", + mock_load_icons_files, + ), patch( + "homeassistant.helpers.icon.async_get_integrations", + return_value={"component1": integration}, + ): + times = 5 + all_icons = [await icon.async_get_icons(hass, "entity") for _ in range(times)] + + assert all_icons == [ + {"component1": {"climate": {"test": {"icon": "mdi:home"}}}} + for _ in range(times) + ] + assert load_count == 1 + + +async def test_caching(hass: HomeAssistant) -> None: + """Test we cache data.""" + hass.config.components.add("binary_sensor") + hass.config.components.add("switch") + + # Patch with same method so we can count invocations + with patch( + "homeassistant.helpers.icon.build_resources", + side_effect=icon.build_resources, + ) as mock_build: + load1 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + load2 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + assert load1 == load2 + + assert load1["binary_sensor"] + assert load1["switch"] + + load_switch_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_switch_only + assert list(load_switch_only) == ["switch"] + + load_binary_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"binary_sensor"} + ) + assert load_binary_sensor_only + assert list(load_binary_sensor_only) == ["binary_sensor"] + + # Check if new loaded component, trigger load + hass.config.components.add("media_player") + with patch( + "homeassistant.helpers.icon._load_icons_files", + side_effect=icon._load_icons_files, + ) as mock_load: + load_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_sensor_only + assert len(mock_load.mock_calls) == 0 + + await icon.async_get_icons( + hass, "entity_component", integrations={"media_player"} + ) + assert len(mock_load.mock_calls) == 1 diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 8d473338058..0486211417c 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_json.py b/tests/helpers/test_json.py index 7e248c8c381..2106a397baf 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -19,12 +19,15 @@ from homeassistant.helpers.json import ( json_bytes_strip_null, json_dumps, json_dumps_sorted, + json_fragment, save_json, ) from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor from homeassistant.util.json import SerializationError, load_json +from tests.common import json_round_trip + # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} TEST_JSON_B = {"a": "one", "B": 2} @@ -45,7 +48,8 @@ def test_json_encoder(hass: HomeAssistant, encoder: type[json.JSONEncoder]) -> N assert sorted(ha_json_enc.default(data)) == sorted(data) # Test serializing an object which implements as_dict - assert ha_json_enc.default(state) == state.as_dict() + default = ha_json_enc.default(state) + assert json_round_trip(default) == json_round_trip(state.as_dict()) def test_json_encoder_raises(hass: HomeAssistant) -> None: @@ -133,6 +137,35 @@ def test_json_dumps_rgb_color_subclass() -> None: assert json_dumps(rgb) == "[4,2,1]" +def test_json_fragments() -> None: + """Test the json dumps with a fragment.""" + + assert ( + json_dumps( + [ + json_fragment('{"inner":"fragment2"}'), + json_fragment('{"inner":"fragment2"}'), + ] + ) + == '[{"inner":"fragment2"},{"inner":"fragment2"}]' + ) + + class Fragment1: + @property + def json_fragment(self): + return json_fragment('{"inner":"fragment1"}') + + class Fragment2: + @property + def json_fragment(self): + return json_fragment('{"inner":"fragment2"}') + + assert ( + json_dumps([Fragment1(), Fragment2()]) + == '[{"inner":"fragment1"},{"inner":"fragment2"}]' + ) + + def test_json_bytes_strip_null() -> None: """Test stripping nul from strings.""" diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index d69996e5d29..2a01439ccbd 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -31,6 +31,7 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, + json_round_trip, mock_integration, mock_platform, ) @@ -209,7 +210,7 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: async def test_hass_starting(hass: HomeAssistant) -> None: """Test that we cache data.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) now = dt_util.utcnow() stored_states = [ @@ -223,7 +224,7 @@ async def test_hass_starting(hass: HomeAssistant) -> None: await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) hass.data.pop(DATA_RESTORE_STATE) await async_load(hass) data = async_get(hass) @@ -318,12 +319,15 @@ async def test_dump_data(hass: HomeAssistant) -> None: # b4 should not be written, since it is now expired # b5 should be written, since current state is restored by entity registry assert len(written_states) == 3 - assert written_states[0]["state"]["entity_id"] == "input_boolean.b1" - assert written_states[0]["state"]["state"] == "on" - assert written_states[1]["state"]["entity_id"] == "input_boolean.b3" - assert written_states[1]["state"]["state"] == "off" - assert written_states[2]["state"]["entity_id"] == "input_boolean.b5" - assert written_states[2]["state"]["state"] == "off" + state0 = json_round_trip(written_states[0]) + state1 = json_round_trip(written_states[1]) + state2 = json_round_trip(written_states[2]) + assert state0["state"]["entity_id"] == "input_boolean.b1" + assert state0["state"]["state"] == "on" + assert state1["state"]["entity_id"] == "input_boolean.b3" + assert state1["state"]["state"] == "off" + assert state2["state"]["entity_id"] == "input_boolean.b5" + assert state2["state"]["state"] == "off" # Test that removed entities are not persisted await entity.async_remove() @@ -340,10 +344,12 @@ async def test_dump_data(hass: HomeAssistant) -> None: args = mock_write_data.mock_calls[0][1] written_states = args[0] assert len(written_states) == 2 - assert written_states[0]["state"]["entity_id"] == "input_boolean.b3" - assert written_states[0]["state"]["state"] == "off" - assert written_states[1]["state"]["entity_id"] == "input_boolean.b5" - assert written_states[1]["state"]["state"] == "off" + state0 = json_round_trip(written_states[0]) + state1 = json_round_trip(written_states[1]) + assert state0["state"]["entity_id"] == "input_boolean.b3" + assert state0["state"]["state"] == "off" + assert state1["state"]["entity_id"] == "input_boolean.b5" + assert state1["state"]["state"] == "off" async def test_dump_error(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 1ea602f7cda..b0136fdebc9 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -7,7 +7,7 @@ import logging import operator from types import MappingProxyType from unittest import mock -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch from freezegun import freeze_time import pytest @@ -31,7 +31,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -41,6 +41,7 @@ from homeassistant.helpers import ( trace, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -79,36 +80,32 @@ def compare_result_item(key, actual, expected, path): assert actual == expected -def assert_element(trace_element, expected_element, path): +ANY_CONTEXT = {"context": Context(id=ANY)} + + +def assert_element(trace_element, expected_element, path, numeric_path): """Assert a trace element is as expected. - Note: Unused variable 'path' is passed to get helpful errors from pytest. + Note: Unused variable 'numeric_path' is passed to get helpful errors from pytest. """ - expected_result = expected_element.get("result", {}) + expected_element = dict(expected_element) - # Check that every item in expected_element is present and equal in trace_element - # The redundant set operation gives helpful errors from pytest - assert not set(expected_result) - set(trace_element._result or {}) - for result_key, result in expected_result.items(): - compare_result_item(result_key, trace_element._result[result_key], result, path) - assert trace_element._result[result_key] == result - - # Check for unexpected items in trace_element - assert not set(trace_element._result or {}) - set(expected_result) - - if "error_type" in expected_element and expected_element["error_type"] is not None: - assert isinstance(trace_element._error, expected_element["error_type"]) - else: - assert trace_element._error is None - - # Don't check variables when script starts + # Ignore the context variable in the first step, take care to not mutate if trace_element.path == "0": - return + variables = expected_element.setdefault("variables", {}) + expected_element["variables"] = variables | ANY_CONTEXT - if "variables" in expected_element: - assert expected_element["variables"] == trace_element._variables - else: - assert not trace_element._variables + # Rename variables to changed_variables + if variables := expected_element.pop("variables", None): + expected_element["changed_variables"] = variables + + # Set expected path + expected_element["path"] = str(path) + + # Ignore timestamp + expected_element["timestamp"] = ANY + + assert trace_element.as_dict() == expected_element def assert_action_trace(expected, expected_script_execution="finished"): @@ -122,7 +119,7 @@ def assert_action_trace(expected, expected_script_execution="finished"): assert len(action_trace[key]) == len(expected[key]) for index, element in enumerate(expected[key]): path = f"[{trace_key_index}][{index}]" - assert_element(action_trace[key][index], element, path) + assert_element(action_trace[key][index], element, key, path) assert script_execution == expected_script_execution @@ -234,7 +231,8 @@ async def test_firing_event_template(hass: HomeAssistant) -> None: "list": ["yes", "yesyes"], "list2": ["yes", "yesyes"], }, - } + }, + "variables": {"is_world": "yes"}, } ], } @@ -326,7 +324,8 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: "target": {}, }, "running_script": False, - } + }, + "variables": {"is_world": "yes"}, } ], } @@ -484,7 +483,8 @@ async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: "target": {}, }, "running_script": False, - } + }, + "variables": {"hello_var": "hello"}, } ], } @@ -772,7 +772,11 @@ async def test_delay_template_invalid( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": vol.MultipleInvalid}], + "1": [ + { + "error": "offset should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" + } + ], }, expected_script_execution="aborted", ) @@ -835,7 +839,7 @@ async def test_delay_template_complex_invalid( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": vol.MultipleInvalid}], + "1": [{"error": "expected float for dictionary value @ data['seconds']"}], }, expected_script_execution="aborted", ) @@ -917,20 +921,40 @@ async def test_wait_basic(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": {"wait": {"completed": True, "remaining": None}}, + } + ], } ) else: + expected_trigger = { + "alias": None, + "attribute": None, + "description": "state of switch.test", + "entity_id": "switch.test", + "for": None, + "from_state": ANY, + "id": "0", + "idx": "0", + "platform": "state", + "to_state": ANY, + } assert_action_trace( { "0": [ { "result": { "wait": { - "trigger": {"description": "state of switch.test"}, + "trigger": expected_trigger, "remaining": None, } - } + }, + "variables": { + "wait": {"remaining": None, "trigger": expected_trigger} + }, } ], } @@ -1013,13 +1037,23 @@ async def test_wait_basic_times_out(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], } ) else: assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -1127,14 +1161,24 @@ async def test_cancel_wait(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], }, expected_script_execution="cancelled", ) else: assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], }, expected_script_execution="cancelled", ) @@ -1287,15 +1331,17 @@ async def test_wait_continue_on_timeout( assert len(events) == n_events if action_type == "template": - variable_wait = {"wait": {"completed": False, "remaining": 0.0}} + result_wait = {"wait": {"completed": False, "remaining": 0.0}} + variable_wait = dict(result_wait) else: - variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} + result_wait = {"wait": {"trigger": None, "remaining": 0.0}} + variable_wait = dict(result_wait) expected_trace = { - "0": [{"result": variable_wait, "variables": variable_wait}], + "0": [{"result": result_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_trace["0"][0]["error"] = "TimeoutError" expected_script_execution = "aborted" else: expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] @@ -1328,7 +1374,15 @@ async def test_wait_template_variables_in(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": { + "data": "switch.test", + "wait": {"completed": True, "remaining": None}, + }, + } + ], } ) @@ -1359,7 +1413,12 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": {"wait": {"completed": True, "remaining": None}}, + } + ], } ) @@ -1393,7 +1452,12 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], } ) @@ -1497,7 +1561,12 @@ async def test_wait_for_trigger_bad( assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -1533,7 +1602,12 @@ async def test_wait_for_trigger_generated_exception( assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -1573,7 +1647,14 @@ async def test_condition_warning( { "0": [{"result": {"event": "test_event", "event_data": {}}}], "1": [{"result": {"result": False}}], - "1/entity_id/0": [{"error_type": ConditionError}], + "1/entity_id/0": [ + { + "error": ( + "In 'numeric_state' condition: entity test.entity state " + "'string' cannot be processed as a number" + ) + } + ], }, expected_script_execution="aborted", ) @@ -1668,7 +1749,7 @@ async def test_condition_subscript( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {}}], + "1": [{}], "1/repeat/sequence/0": [ { "variables": {"repeat": {"first": True, "index": 1}}, @@ -2195,11 +2276,7 @@ async def test_repeat_for_each_non_list_template(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [ - { - "error_type": script._AbortScript, - } - ], + "0": [{"error": "Repeat 'for_each' must be a list of items"}], }, expected_script_execution="aborted", ) @@ -2234,7 +2311,7 @@ async def test_repeat_for_each_invalid_template( assert_action_trace( { - "0": [{"error_type": script._AbortScript}], + "0": [{"error": "Repeat 'for_each' must be a list of items"}], }, expected_script_execution="aborted", ) @@ -2296,10 +2373,14 @@ async def test_repeat_condition_warning( "variables": {"repeat": {"first": True, "index": 1}}, } ] - expected_trace[f"0/repeat/{condition}/0"] = [{"error_type": ConditionError}] - expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [ - {"error_type": ConditionError} + expected_error = ( + "In 'numeric_state' condition: entity sensor.test state '' cannot " + "be processed as a number" + ) + expected_trace[f"0/repeat/{condition}/0"] = [ + {"error": "In 'numeric_state':\n " + expected_error} ] + expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [{"error": expected_error}] assert_action_trace(expected_trace) @@ -2421,7 +2502,7 @@ async def test_repeat_until_condition_validation( assert_action_trace( { - "0": [{"result": {}}], + "0": [{}], "0/repeat/sequence/0": [ { "result": {"event": "test_event", "event_data": {}}, @@ -2484,7 +2565,7 @@ async def test_repeat_while_condition_validation( assert_action_trace( { - "0": [{"result": {}}], + "0": [{}], "0/repeat": [ { "result": {"result": False}, @@ -2588,7 +2669,7 @@ async def test_repeat_var_in_condition(hass: HomeAssistant, condition) -> None: @pytest.mark.parametrize( ("variables", "first_last", "inside_x"), [ - (None, {"repeat": None, "x": None}, None), + (MappingProxyType({}), {"repeat": None, "x": None}, None), (MappingProxyType({"x": 1}), {"repeat": None, "x": 1}, 1), ], ) @@ -2691,7 +2772,12 @@ async def test_repeat_nested( {"repeat": {"first": False, "index": 2, "last": True}}, ] expected_trace = { - "0": [{"result": {"event": "test_event", "event_data": event_data1}}], + "0": [ + { + "result": {"event": "test_event", "event_data": event_data1}, + "variables": variables, + } + ], "1": [{}], "1/repeat/sequence/0": [ { @@ -2838,7 +2924,14 @@ async def test_choose( if var == 3: expected_choice = "default" - expected_trace = {"0": [{"result": {"choice": expected_choice}}]} + expected_trace = { + "0": [ + { + "result": {"choice": expected_choice}, + "variables": {"var": var}, + } + ] + } if var >= 1: expected_trace["0/choose/0"] = [{"result": {"result": var == 1}}] expected_trace["0/choose/0/conditions/0"] = [ @@ -2933,7 +3026,7 @@ async def test_choose_condition_validation( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {}}], + "1": [{}], "1/choose/0": [{"result": {"result": False}}], "1/choose/0/conditions/0": [{"result": {"result": False}}], "1/choose/0/conditions/0/entity_id/0": [ @@ -3060,7 +3153,7 @@ async def test_if( assert f"Test Name: If at step 1: Executing step if {choice}" in caplog.text expected_trace = { - "0": [{"result": {"choice": choice}}], + "0": [{"result": {"choice": choice}, "variables": {"var": var}}], "0/if": [{"result": {"result": if_result}}], "0/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"0/{choice}/0": [ @@ -3252,20 +3345,29 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - in caplog.text ) + expected_trigger = { + "alias": None, + "attribute": None, + "description": "state of switch.trigger", + "entity_id": "switch.trigger", + "for": None, + "from_state": ANY, + "id": "0", + "idx": "0", + "platform": "state", + "to_state": ANY, + } expected_trace = { - "0": [{"result": {}}], + "0": [{"variables": {"what": "world"}}], "0/parallel/0/sequence/0": [ { "result": { "wait": { "remaining": None, - "trigger": { - "entity_id": "switch.trigger", - "description": "state of switch.trigger", - }, + "trigger": expected_trigger, } }, - "variables": {"wait": {"remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": expected_trigger}}, } ], "0/parallel/1/sequence/0": [ @@ -3351,13 +3453,9 @@ async def test_parallel_loop( assert events_loop2[2].data["hello2"] == "loop2_c" expected_trace = { - "0": [{"result": {}}], - "0/parallel/0/sequence/0": [{"result": {}}], - "0/parallel/1/sequence/0": [ - { - "result": {}, - } - ], + "0": [{"variables": {"what": "world"}}], + "0/parallel/0/sequence/0": [{}], + "0/parallel/1/sequence/0": [{}], "0/parallel/0/sequence/0/repeat/sequence/0": [ { "variables": { @@ -3452,10 +3550,10 @@ async def test_parallel_error( assert len(events) == 0 expected_trace = { - "0": [{"error_type": ServiceNotFound, "result": {}}], + "0": [{"error": "Service epic.failure not found."}], "0/parallel/0/sequence/0": [ { - "error_type": ServiceNotFound, + "error": "Service epic.failure not found.", "result": { "params": { "domain": "epic", @@ -3503,7 +3601,7 @@ async def test_propagate_error_service_not_found(hass: HomeAssistant) -> None: expected_trace = { "0": [ { - "error_type": ServiceNotFound, + "error": "Service test.script not found.", "result": { "params": { "domain": "test", @@ -3539,7 +3637,7 @@ async def test_propagate_error_invalid_service_data(hass: HomeAssistant) -> None expected_trace = { "0": [ { - "error_type": vol.MultipleInvalid, + "error": "expected str for dictionary value @ data['text']", "result": { "params": { "domain": "test", @@ -3579,7 +3677,7 @@ async def test_propagate_error_service_exception(hass: HomeAssistant) -> None: expected_trace = { "0": [ { - "error_type": ValueError, + "error": "BROKEN", "result": { "params": { "domain": "test", @@ -4340,7 +4438,7 @@ async def test_shutdown_after( script_obj = script.Script(hass, sequence, "test script", "test_domain") delay_started_flag = async_watch_for_action(script_obj, delay_alias) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await hass.async_block_till_done() @@ -4379,7 +4477,7 @@ async def test_start_script_after_shutdown( script_obj = script.Script(hass, sequence, "test script", "test_domain") # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await hass.async_block_till_done() # Trigger 2nd stage script shutdown @@ -4601,6 +4699,9 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_PARALLEL: { "parallel": [templated_device_action("parallel_event")], }, + cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: { + "set_conversation_response": "Hello world" + }, } expected_templates = { cv.SCRIPT_ACTION_CHECK_CONDITION: None, @@ -4914,17 +5015,17 @@ async def test_stop_action( @pytest.mark.parametrize( - ("error", "error_type", "logmsg", "script_execution"), + ("error", "error_dict", "logmsg", "script_execution"), ( - (True, script._AbortScript, "Error", "aborted"), - (False, None, "Stop", "finished"), + (True, {"error": "In the name of love"}, "Error", "aborted"), + (False, {}, "Stop", "finished"), ), ) async def test_stop_action_subscript( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, error, - error_type, + error_dict, logmsg, script_execution, ) -> None: @@ -4963,14 +5064,11 @@ async def test_stop_action_subscript( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": error_type, "result": {"choice": "then"}}], + "1": [{"result": {"choice": "then"}} | error_dict], "1/if": [{"result": {"result": True}}], "1/if/condition/0": [{"result": {"result": True, "entities": []}}], "1/then/0": [ - { - "error_type": error_type, - "result": {"stop": "In the name of love", "error": error}, - } + {"result": {"stop": "In the name of love", "error": error}} | error_dict ], }, expected_script_execution=script_execution, @@ -5010,7 +5108,7 @@ async def test_stop_action_with_error( "0": [{"result": {"event": "test_event", "event_data": {}}}], "1": [ { - "error_type": script._AbortScript, + "error": "Epic one...", "result": {"stop": "Epic one...", "error": True}, } ], @@ -5071,7 +5169,7 @@ async def test_continue_on_error(hass: HomeAssistant) -> None: "2": [{"result": {"event": "test_event", "event_data": {}}}], "3": [ { - "error_type": HomeAssistantError, + "error": "It is not working!", "result": { "params": { "domain": "broken", @@ -5129,7 +5227,7 @@ async def test_continue_on_error_automation_issue(hass: HomeAssistant) -> None: { "0": [ { - "error_type": ServiceNotFound, + "error": "Service service.not_found not found.", "result": { "params": { "domain": "service", @@ -5176,7 +5274,7 @@ async def test_continue_on_error_unknown_error(hass: HomeAssistant) -> None: { "0": [ { - "error_type": MyLibraryError, + "error": "It is not working!", "result": { "params": { "domain": "some", @@ -5357,3 +5455,168 @@ async def test_condition_not_shorthand( "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) + + +async def test_conversation_response( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting conversation response.""" + sequence = cv.SCRIPT_SCHEMA([{"set_conversation_response": "Testing 123"}]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response == "Testing 123" + + assert_action_trace( + { + "0": [{"result": {"conversation_response": "Testing 123"}}], + } + ) + + +async def test_conversation_response_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test a templated conversation response.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"my_var": "234"}}, + {"set_conversation_response": '{{ "Testing " + my_var }}'}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response == "Testing 234" + + assert_action_trace( + { + "0": [{"variables": {"my_var": "234"}}], + "1": [{"result": {"conversation_response": "Testing 234"}}], + } + ) + + +async def test_conversation_response_not_set( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test not setting conversation response.""" + sequence = cv.SCRIPT_SCHEMA([]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response is UNDEFINED + + assert_action_trace({}) + + +async def test_conversation_response_unset( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test clearing conversation response.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + {"set_conversation_response": None}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response is None + + assert_action_trace( + { + "0": [{"result": {"conversation_response": "Testing 123"}}], + "1": [{"result": {"conversation_response": None}}], + } + ) + + +@pytest.mark.parametrize( + ("var", "if_result", "choice", "response"), + [(1, True, "then", "If: Then"), (2, False, "else", "If: Else")], +) +async def test_conversation_response_subscript_if( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, + response: str, +) -> None: + """Test setting conversation response in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": {"set_conversation_response": "If: Then"}, + "else": {"set_conversation_response": "If: Else"}, + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.conversation_response == response + + expected_trace = { + "0": [ + { + "result": {"conversation_response": "Testing 123"}, + "variables": {"var": var}, + } + ], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + f"1/{choice}/0": [{"result": {"conversation_response": response}}], + } + assert_action_trace(expected_trace) + + +@pytest.mark.parametrize( + ("var", "if_result", "choice"), [(1, True, "then"), (2, False, "else")] +) +async def test_conversation_response_not_set_subscript_if( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, +) -> None: + """Test not setting conversation response in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [], + "else": [], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.conversation_response == "Testing 123" + + expected_trace = { + "0": [ + { + "result": {"conversation_response": "Testing 123"}, + "variables": {"var": var}, + } + ], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + } + assert_action_trace(expected_trace) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index e925b425f96..00942b396e8 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -858,6 +858,11 @@ def test_language_selector_schema(schema, valid_selections, invalid_selections) "longitude": 2.0, "radius": 3.0, }, + { + "latitude": 1, + "longitude": 2, + "radius": 3, + }, ), ( None, @@ -865,7 +870,6 @@ def test_language_selector_schema(schema, valid_selections, invalid_selections) {}, {"latitude": 1.0}, {"longitude": 1.0}, - {"latitude": 1.0, "longitude": "1.0"}, ), ), ), @@ -1108,3 +1112,32 @@ def test_condition_selector_schema( def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test trigger sequence selector.""" _test_selector("trigger", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {"data": "test", "scale": 5}, + ("test",), + (False, 0, []), + ), + ( + {"data": "test"}, + ("test",), + (True, 1, []), + ), + ( + { + "data": "test", + "scale": 5, + "error_correction_level": selector.QrErrorCorrectionLevel.HIGH, + }, + ("test",), + (True, 1, []), + ), + ), +) +def test_qr_code_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test QR code selector.""" + _test_selector("qr_code", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 04324cdbfa3..90f9b65aaba 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,4 +1,5 @@ """Test service helpers.""" +import asyncio from collections.abc import Iterable from copy import deepcopy from typing import Any @@ -19,7 +20,13 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import ( + Context, + HassJob, + HomeAssistant, + ServiceCall, + SupportsResponse, +) from homeassistant.helpers import ( device_registry as dr, entity_registry as er, @@ -776,6 +783,84 @@ async def test_async_get_all_descriptions_dynamically_created_services( } +async def test_async_get_all_descriptions_new_service_added_while_loading( + hass: HomeAssistant, +) -> None: + """Test async_get_all_descriptions when a new service is added while loading translations.""" + group = hass.components.group + group_config = {group.DOMAIN: {}} + await async_setup_component(hass, group.DOMAIN, group_config) + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert "description" in descriptions["group"]["reload"] + assert "fields" in descriptions["group"]["reload"] + + logger = hass.components.logger + logger_domain = logger.DOMAIN + logger_config = {logger_domain: {}} + + translations_called = asyncio.Event() + translations_wait = asyncio.Event() + + 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.""" + translations_called.set() + await translations_wait.wait() + translation_key_prefix = f"component.{logger_domain}.services.set_default_level" + return { + f"{translation_key_prefix}.name": "Translated name", + f"{translation_key_prefix}.description": "Translated description", + f"{translation_key_prefix}.fields.level.name": "Field name", + f"{translation_key_prefix}.fields.level.description": "Field description", + f"{translation_key_prefix}.fields.level.example": "Field example", + } + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + side_effect=async_get_translations, + ): + await async_setup_component(hass, logger_domain, logger_config) + task = asyncio.create_task(service.async_get_all_descriptions(hass)) + await translations_called.wait() + # Now register a new service while translations are being loaded + hass.services.async_register(logger_domain, "new_service", lambda x: None, None) + service.async_set_service_schema( + hass, logger_domain, "new_service", {"description": "new service"} + ) + translations_wait.set() + descriptions = await task + + # Two domains should be present + assert len(descriptions) == 2 + + logger_descriptions = descriptions[logger_domain] + + # The new service was loaded after the translations were loaded + # so it should not appear until the next time we fetch + assert "new_service" not in logger_descriptions + + set_default_level = logger_descriptions["set_default_level"] + + assert set_default_level["name"] == "Translated name" + assert set_default_level["description"] == "Translated description" + set_default_level_fields = set_default_level["fields"] + assert set_default_level_fields["level"]["name"] == "Field name" + assert set_default_level_fields["level"]["description"] == "Field description" + assert set_default_level_fields["level"]["example"] == "Field example" + + descriptions = await service.async_get_all_descriptions(hass) + assert "description" in descriptions[logger_domain]["new_service"] + assert descriptions[logger_domain]["new_service"]["description"] == "new service" + + async def test_register_with_mixed_case(hass: HomeAssistant) -> None: """Test registering a service with mixed case. @@ -802,8 +887,8 @@ 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)], - test_service_mock, + mock_entities, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], ) @@ -821,8 +906,8 @@ 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)], - test_service_mock, + mock_entities, + HassJob(test_service_mock), ServiceCall( "test_domain", "test_service", {"entity_id": "light.living_room"} ), @@ -838,8 +923,8 @@ 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)], - test_service_mock, + mock_entities, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], ) @@ -857,8 +942,8 @@ 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)], - test_service_mock, + mock_entities, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], ) @@ -878,8 +963,8 @@ 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)], - test_service_mock, + mock_entities, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), ) assert test_service_mock.call_count == 1 @@ -890,7 +975,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 +993,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 +1020,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 +1048,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 +1072,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 +1092,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 +1111,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 +1133,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 +1150,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_start.py b/tests/helpers/test_start.py index ec7ffbc9afc..d203b336f27 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -8,7 +8,7 @@ from homeassistant.helpers import start async def test_at_start_when_running_awaitable(hass: HomeAssistant) -> None: """Test at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -21,7 +21,7 @@ async def test_at_start_when_running_awaitable(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 1 - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert hass.is_running start.async_at_start(hass, cb_at_start) @@ -33,7 +33,7 @@ async def test_at_start_when_running_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -46,7 +46,7 @@ async def test_at_start_when_running_callback( start.async_at_start(hass, cb_at_start)() assert len(calls) == 1 - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert hass.is_running start.async_at_start(hass, cb_at_start)() @@ -59,7 +59,7 @@ async def test_at_start_when_running_callback( async def test_at_start_when_starting_awaitable(hass: HomeAssistant) -> None: """Test at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -81,7 +81,7 @@ async def test_at_start_when_starting_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -110,7 +110,7 @@ async def test_cancelling_at_start_when_running( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancelling at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -130,7 +130,7 @@ async def test_cancelling_at_start_when_running( async def test_cancelling_at_start_when_starting(hass: HomeAssistant) -> None: """Test cancelling at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -151,7 +151,7 @@ async def test_cancelling_at_start_when_starting(hass: HomeAssistant) -> None: async def test_at_started_when_running_awaitable(hass: HomeAssistant) -> None: """Test at started when already started.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running calls = [] @@ -164,7 +164,7 @@ async def test_at_started_when_running_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 1 # Test the job is not run if state is CoreState.starting - hass.state = CoreState.starting + hass.set_state(CoreState.starting) start.async_at_started(hass, cb_at_start) await hass.async_block_till_done() @@ -175,7 +175,7 @@ async def test_at_started_when_running_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at started when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running calls = [] @@ -188,7 +188,7 @@ async def test_at_started_when_running_callback( assert len(calls) == 1 # Test the job is not run if state is CoreState.starting - hass.state = CoreState.starting + hass.set_state(CoreState.starting) start.async_at_started(hass, cb_at_start)() assert len(calls) == 1 @@ -200,7 +200,7 @@ async def test_at_started_when_running_callback( async def test_at_started_when_starting_awaitable(hass: HomeAssistant) -> None: """Test at started when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = [] @@ -225,7 +225,7 @@ async def test_at_started_when_starting_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at started when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = [] @@ -257,7 +257,7 @@ async def test_cancelling_at_started_when_running( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancelling at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -277,7 +277,7 @@ async def test_cancelling_at_started_when_running( async def test_cancelling_at_started_when_starting(hass: HomeAssistant) -> None: """Test cancelling at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 85aa4d2de0e..4506d827096 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -140,7 +140,7 @@ async def test_saving_on_final_write( assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -164,7 +164,7 @@ async def test_not_delayed_saving_while_stopping( store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) store.async_delay_save(lambda: MOCK_DATA, 1) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) @@ -181,7 +181,7 @@ async def test_not_delayed_saving_after_stopping( assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert store.key not in hass_storage @@ -195,7 +195,7 @@ async def test_not_saving_while_stopping( ) -> None: """Test saves don't write when stopping Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await store.async_save(MOCK_DATA) assert store.key not in hass_storage @@ -723,7 +723,7 @@ async def test_read_only_store( assert read_only_store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b70c9479abb..955cd2fd65e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1151,7 +1151,6 @@ def test_as_datetime(hass: HomeAssistant, input) -> None: expected = dt_util.parse_datetime(input) if expected is not None: expected = str(expected) - assert ( template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() == expected @@ -1162,34 +1161,64 @@ def test_as_datetime(hass: HomeAssistant, input) -> None: ) -def test_as_datetime_from_timestamp(hass: HomeAssistant) -> None: - """Test converting a UNIX timestamp to a date object.""" - tests = [ +@pytest.mark.parametrize( + ("input", "output"), + [ (1469119144, "2016-07-21 16:39:04+00:00"), (1469119144.0, "2016-07-21 16:39:04+00:00"), (-1, "1969-12-31 23:59:59+00:00"), - ] - for input, output in tests: - # expected = dt_util.parse_datetime(input) - if output is not None: - output = str(output) + ], +) +def test_as_datetime_from_timestamp( + hass: HomeAssistant, + input: int | float, + output: str, +) -> None: + """Test converting a UNIX timestamp to a date object.""" + assert ( + template.Template(f"{{{{ as_datetime({input}) }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ {input} | as_datetime }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ '{input}' | as_datetime }}}}", hass).async_render() + == output + ) - assert ( - template.Template(f"{{{{ as_datetime({input}) }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ {input} | as_datetime }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ '{input}' | as_datetime }}}}", hass).async_render() - == output - ) + +@pytest.mark.parametrize( + ("input", "default", "output"), + [ + (1469119144, 123, "2016-07-21 16:39:04+00:00"), + ('"invalid"', ["default output"], ["default output"]), + (["a", "list"], 0, 0), + ({"a": "dict"}, None, None), + ], +) +def test_as_datetime_default( + hass: HomeAssistant, input: Any, default: Any, output: str +) -> None: + """Test invalid input and return default value.""" + + assert ( + template.Template( + f"{{{{ as_datetime({input}, default={default}) }}}}", hass + ).async_render() + == output + ) + assert ( + template.Template( + f"{{{{ {input} | as_datetime({default}) }}}}", hass + ).async_render() + == output + ) def test_as_local(hass: HomeAssistant) -> None: @@ -1728,6 +1757,26 @@ def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) - assert tpl.async_render_with_possible_json_value(value) == expected +def test_render_with_possible_json_value_and_parse_result(hass: HomeAssistant) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=True + ) + assert isinstance(result, dict) + + +def test_render_with_possible_json_value_and_dont_parse_result( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=False + ) + assert isinstance(result, str) + + def test_if_state_exists(hass: HomeAssistant) -> None: """Test if state exists works.""" hass.states.async_set("test.object", "available") @@ -2405,6 +2454,22 @@ def test_bitwise_or(hass: HomeAssistant) -> None: assert tpl.async_render() == 8 | 2 +@pytest.mark.parametrize( + ("value", "xor_value", "expected"), + [(8, 8, 0), (10, 2, 8), (0x8000, 0xFAFA, 31482), (True, False, 1), (True, True, 0)], +) +def test_bitwise_xor( + hass: HomeAssistant, value: Any, xor_value: Any, expected: int +) -> None: + """Test bitwise_xor method.""" + assert ( + template.Template("{{ value | bitwise_xor(xor_value) }}", hass).async_render( + {"value": value, "xor_value": xor_value} + ) + == expected + ) + + def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct pack method.""" diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 62152299932..954c9ae7616 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -43,7 +43,7 @@ async def test_component_translation_path( "switch", {"switch": [{"platform": "test"}, {"platform": "test_embedded"}]}, ) - assert await async_setup_component(hass, "test_package", {"test_package"}) + assert await async_setup_component(hass, "test_package", {"test_package": None}) ( int_test, @@ -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: @@ -407,8 +478,8 @@ async def test_caching(hass: HomeAssistant) -> None: # Patch with same method so we can count invocations with patch( - "homeassistant.helpers.translation._build_resources", - side_effect=translation._build_resources, + "homeassistant.helpers.translation.build_resources", + side_effect=translation.build_resources, ) as mock_build: load_sensor_only = await translation.async_get_translations( hass, "en", "title", integrations={"sensor"} diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 182ed6c3cb4..6497382ab9a 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -506,7 +506,7 @@ async def test_stop_refresh_on_ha_stop( # Fire Home Assistant stop event hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() # Make sure no update with subscriber after stop event diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 03f637a646f..2aeb5fbd5b7 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -80,3 +80,45 @@ def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) super_call_checker.module = "homeassistant.components.pylint_test" return super_call_checker + + +@pytest.fixture(name="hass_enforce_sorted_platforms", scope="session") +def hass_enforce_sorted_platforms_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_sorted_platforms check.""" + return _load_plugin_from_file( + "hass_enforce_sorted_platforms", + "pylint/plugins/hass_enforce_sorted_platforms.py", + ) + + +@pytest.fixture(name="enforce_sorted_platforms_checker") +def enforce_sorted_platforms_checker_fixture( + hass_enforce_sorted_platforms, linter +) -> BaseChecker: + """Fixture to provide a hass_enforce_sorted_platforms checker.""" + enforce_sorted_platforms_checker = ( + hass_enforce_sorted_platforms.HassEnforceSortedPlatformsChecker(linter) + ) + enforce_sorted_platforms_checker.module = "homeassistant.components.pylint_test" + return enforce_sorted_platforms_checker + + +@pytest.fixture(name="hass_enforce_coordinator_module", scope="session") +def hass_enforce_coordinator_module_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_coordinator_module check.""" + return _load_plugin_from_file( + "hass_enforce_coordinator_module", + "pylint/plugins/hass_enforce_coordinator_module.py", + ) + + +@pytest.fixture(name="enforce_coordinator_module_checker") +def enforce_coordinator_module_fixture( + hass_enforce_coordinator_module, linter +) -> BaseChecker: + """Fixture to provide a hass_enforce_coordinator_module checker.""" + enforce_coordinator_module_checker = ( + hass_enforce_coordinator_module.HassEnforceCoordinatorModule(linter) + ) + enforce_coordinator_module_checker.module = "homeassistant.components.pylint_test" + return enforce_coordinator_module_checker diff --git a/tests/pylint/test_enforce_coordinator_module.py b/tests/pylint/test_enforce_coordinator_module.py new file mode 100644 index 00000000000..746da8c1d7e --- /dev/null +++ b/tests/pylint/test_enforce_coordinator_module.py @@ -0,0 +1,133 @@ +"""Tests for pylint hass_enforce_coordinator_module plugin.""" +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import UNDEFINED +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + """, + id="simple", + ), + pytest.param( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + + class TestCoordinator2(TestCoordinator): + pass + """, + id="nested", + ), + ], +) +def test_enforce_coordinator_module_good( + linter: UnittestLinter, enforce_coordinator_module_checker: BaseChecker, code: str +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test.coordinator") + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_enforce_coordinator_module_bad_simple( + linter: UnittestLinter, + enforce_coordinator_module_checker: BaseChecker, +) -> None: + """Bad test case with coordinator extending directly.""" + root_node = astroid.parse( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + """, + "homeassistant.components.pylint_test", + ) + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=5, + node=root_node.body[1], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=21, + ), + ): + walker.walk(root_node) + + +def test_enforce_coordinator_module_bad_nested( + linter: UnittestLinter, + enforce_coordinator_module_checker: BaseChecker, +) -> None: + """Bad test case with nested coordinators.""" + root_node = astroid.parse( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + + class NopeCoordinator(TestCoordinator): + pass + """, + "homeassistant.components.pylint_test", + ) + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=5, + node=root_node.body[1], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=21, + ), + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=8, + node=root_node.body[2], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=8, + end_col_offset=21, + ), + ): + walker.walk(root_node) diff --git a/tests/pylint/test_enforce_sorted_platforms.py b/tests/pylint/test_enforce_sorted_platforms.py new file mode 100644 index 00000000000..923291411f0 --- /dev/null +++ b/tests/pylint/test_enforce_sorted_platforms.py @@ -0,0 +1,71 @@ +"""Tests for pylint hass_enforce_sorted_platforms plugin.""" +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import UNDEFINED +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + PLATFORMS = [Platform.SENSOR] + """, + id="one_platform", + ), + pytest.param( + """ + PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + """, + id="multiple_platforms", + ), + ], +) +def test_enforce_sorted_platforms( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_sorted_platforms_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_enforce_sorted_platforms_bad( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, +) -> None: + """Bad test case.""" + assign_node = astroid.extract_node( + """ + PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + """, + "homeassistant.components.pylint_test", + ) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-sorted-platforms", + line=2, + node=assign_node, + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=2, + end_col_offset=70, + ), + ): + enforce_sorted_platforms_checker.visit_assign(assign_node) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4c350168d4e..a899b3b3d6c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -87,12 +87,21 @@ async def test_async_enable_logging( async def test_load_hassio(hass: HomeAssistant) -> None: - """Test that we load Hass.io component.""" + """Test that we load the hassio integration when using Supervisor.""" with patch.dict(os.environ, {}, clear=True): - assert bootstrap._get_domains(hass, {}) == set() + assert "hassio" not in bootstrap._get_domains(hass, {}) with patch.dict(os.environ, {"SUPERVISOR": "1"}): - assert bootstrap._get_domains(hass, {}) == {"hassio"} + assert "hassio" in bootstrap._get_domains(hass, {}) + + +async def test_load_backup(hass: HomeAssistant) -> None: + """Test that we load the backup integration when not using Supervisor.""" + with patch.dict(os.environ, {}, clear=True): + assert "backup" in bootstrap._get_domains(hass, {}) + + with patch.dict(os.environ, {"SUPERVISOR": "1"}): + assert "backup" not in bootstrap._get_domains(hass, {}) @pytest.mark.parametrize("load_registries", [False]) @@ -471,7 +480,6 @@ async def test_setup_hass( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -521,7 +529,6 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -560,7 +567,6 @@ async def test_setup_hass_invalid_yaml( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch( @@ -588,7 +594,6 @@ async def test_setup_hass_config_dir_nonexistent( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" mock_ensure_config_exists.return_value = False @@ -615,7 +620,6 @@ async def test_setup_hass_recovery_mode( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup") as browser_setup, patch( @@ -650,7 +654,6 @@ async def test_setup_hass_safe_mode( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup"), patch( @@ -683,7 +686,6 @@ async def test_setup_hass_recovery_mode_and_safe_mode( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup"), patch( @@ -716,7 +718,6 @@ async def test_setup_hass_invalid_core_config( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.bootstrap.async_notify_setup_error") as mock_notify: @@ -756,7 +757,6 @@ async def test_setup_recovery_mode_if_no_frontend( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test we setup recovery mode if frontend didn't load.""" verbose = Mock() @@ -784,6 +784,7 @@ async def test_setup_recovery_mode_if_no_frontend( @pytest.mark.parametrize("load_registries", [False]) +@patch("homeassistant.bootstrap.DEFAULT_INTEGRATIONS", set()) async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( hass: HomeAssistant, ) -> None: @@ -836,7 +837,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( assert integrations[0] != {} assert "an_after_dep" in integrations[0] - assert integrations[-3] != {} + assert integrations[-2] != {} assert integrations[-1] == {} assert "normal_integration" in hass.config.components diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e9989b6839e..1c67534d5df 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6,7 +6,7 @@ from collections.abc import Generator from datetime import timedelta import logging from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -19,7 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HomeAssistant, + callback, +) from homeassistant.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -27,7 +33,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -59,7 +65,13 @@ def mock_handlers() -> Generator[None, None, None]: async def async_step_reauth(self, data): """Mock Reauth.""" - return self.async_show_form(step_id="reauth") + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Test reauth confirm step.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return self.async_abort(reason="test") with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} @@ -425,10 +437,15 @@ async def test_remove_entry_cancels_reauth( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + issue_registry = ir.async_get(hass) + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HA_DOMAIN, issue_id) + await manager.async_remove(entry.entry_id) flows = hass.config_entries.flow.async_progress_by_handler("test") assert len(flows) == 0 + assert not issue_registry.async_get_issue(HA_DOMAIN, issue_id) async def test_remove_entry_handles_callback_error( @@ -734,6 +751,7 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: "_integration_for_domain", "_tries", "_setup_again_job", + "_supports_options", } entry = MockConfigEntry(entry_id="mock-entry") @@ -910,6 +928,49 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert "config_entry_reconfigure" not in notifications +async def test_reauth_issue(hass: HomeAssistant) -> None: + """Test that we create/delete an issue when source is reauth.""" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await entry.async_setup(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + + assert len(issue_registry.issues) == 1 + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + issue = issue_registry.async_get_issue(HA_DOMAIN, issue_id) + assert issue == ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=ANY, + data={"flow_id": flows[0]["flow_id"]}, + dismissed_version=None, + domain=HA_DOMAIN, + is_fixable=False, + is_persistent=False, + issue_domain="test", + issue_id=issue_id, + learn_more_url=None, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + translation_placeholders=None, + ) + + result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule("test")) @@ -1084,7 +1145,7 @@ async def test_setup_retrying_during_unload(hass: HomeAssistant) -> None: async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) -> None: """Test if we unload an entry that is in retry mode before started.""" entry = MockConfigEntry(domain="test") - hass.state = CoreState.starting + hass.set_state(CoreState.starting) initial_listeners = hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) @@ -1121,7 +1182,7 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(mock_setup_entry.mock_calls) == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() @@ -1176,6 +1237,7 @@ async def test_create_entry_options( entries = hass.config_entries.async_entries("comp") assert len(entries) == 1 + assert entries[0].supports_options is False assert entries[0].data == {"example": "data"} assert entries[0].options == {"example": "option"} @@ -1202,6 +1264,10 @@ async def test_entry_options( return OptionsFlowHandler() + def async_supports_options_flow(self, entry: MockConfigEntry) -> bool: + """Test options flow.""" + return True + config_entries.HANDLERS["test"] = TestFlow() flow = await manager.options.async_create_flow( entry.entry_id, context={"source": "test"}, data=None @@ -1216,6 +1282,7 @@ async def test_entry_options( assert entry.data == {"first": True} assert entry.options == {"second": True} + assert entry.supports_options is True async def test_entry_options_abort( @@ -3123,6 +3190,9 @@ async def test_updating_entry_with_and_without_changes( state=config_entries.ConfigEntryState.SETUP_ERROR, ) entry.add_to_manager(manager) + assert "abc123" in str(entry) + + assert manager.async_entry_for_domain_unique_id("test", "abc123") is entry assert manager.async_update_entry(entry) is False @@ -3138,6 +3208,10 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry, **change) is True assert manager.async_update_entry(entry, **change) is False + assert manager.async_entry_for_domain_unique_id("test", "abc123") is None + assert manager.async_entry_for_domain_unique_id("test", "abcd1234") is entry + assert "abcd1234" in str(entry) + async def test_entry_reload_calls_on_unload_listeners( hass: HomeAssistant, manager: config_entries.ConfigEntries @@ -4127,3 +4201,59 @@ async def test_preview_not_supported( ) assert result["preview"] is None + + +def test_raise_trying_to_add_same_config_entry_twice( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we log an error if trying to add same config entry twice.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + entry.add_to_hass(hass) + assert f"An entry with the id {entry.entry_id} already exists" in caplog.text + + +async def test_update_entry_and_reload( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test updating an entry and reloading.""" + entry = MockConfigEntry( + domain="comp", + unique_id="1234", + title="Test", + data={"vendor": "data"}, + options={"vendor": "options"}, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "comp.config_flow", None) + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_reauth(self, data): + """Mock Reauth.""" + return self.async_update_reload_and_abort( + entry=entry, + unique_id="5678", + title="Updated Title", + data={"vendor": "data2"}, + options={"vendor": "options2"}, + ) + + with patch.dict(config_entries.HANDLERS, {"comp": MockFlowHandler}): + task = await manager.flow.async_init("comp", context={"source": "reauth"}) + await hass.async_block_till_done() + + assert entry.title == "Updated Title" + assert entry.unique_id == "5678" + assert entry.data == {"vendor": "data2"} + assert entry.options == {"vendor": "options2"} + assert entry.state == config_entries.ConfigEntryState.LOADED + assert task["type"] == FlowResultType.ABORT + assert task["reason"] == "reauth_successful" diff --git a/tests/test_const.py b/tests/test_const.py index 4b9be4f27f1..7ca4812ca8e 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -103,7 +103,13 @@ def test_all() -> None: ], "VOLUME_", ) - + _create_tuples(const.UnitOfVolumeFlowRate, "VOLUME_FLOW_RATE_") + + _create_tuples( + [ + const.UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + const.UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ], + "VOLUME_FLOW_RATE_", + ) + _create_tuples( [ const.UnitOfMass.GRAMS, diff --git a/tests/test_core.py b/tests/test_core.py index bbd27151243..4136249f993 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -54,6 +54,7 @@ from homeassistant.exceptions import ( MaxLengthExceeded, ServiceNotFound, ) +from homeassistant.helpers.json import json_dumps import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import METRIC_SYSTEM @@ -412,7 +413,7 @@ async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): await hass.async_stop() - assert hass.state == CoreState.stopped + assert hass.state is CoreState.stopped async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: @@ -624,6 +625,46 @@ def test_event_eq() -> None: assert event1.as_dict() == event2.as_dict() +def test_event_time_fired_timestamp() -> None: + """Test time_fired_timestamp.""" + now = dt_util.utcnow() + event = ha.Event("some_type", {"some": "attr"}, time_fired=now) + assert event.time_fired_timestamp == now.timestamp() + assert event.time_fired_timestamp == now.timestamp() + + +def test_event_json_fragment() -> None: + """Test event JSON fragments.""" + now = dt_util.utcnow() + data = {"some": "attr"} + context = ha.Context() + event1, event2 = ( + ha.Event("some_type", data, time_fired=now, context=context) for _ in range(2) + ) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = event1.json_fragment + as_dict_1 = event1.as_dict() + as_dict_2 = event2.as_dict() + json_fragment_2 = event2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # as is the data and context inside regardless of + # if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_1["data"], ReadOnlyDict) + assert isinstance(as_dict_1["context"], ReadOnlyDict) + + assert isinstance(as_dict_2, ReadOnlyDict) + assert isinstance(as_dict_2["data"], ReadOnlyDict) + assert isinstance(as_dict_2["context"], ReadOnlyDict) + + def test_event_repr() -> None: """Test that Event repr method works.""" assert str(ha.Event("TestEvent")) == "" @@ -701,9 +742,9 @@ def test_state_as_dict_json() -> None: context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), ) expected = ( - '{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' - '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' - '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' + b'{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' + b'"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' + b'"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' ) as_dict_json_1 = state.as_dict_json assert as_dict_json_1 == expected @@ -712,6 +753,44 @@ def test_state_as_dict_json() -> None: assert state.as_dict_json is as_dict_json_1 +def test_state_json_fragment() -> None: + """Test state JSON fragments.""" + last_time = datetime(1984, 12, 8, 12, 0, 0) + state1, state2 = ( + ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), + ) + for _ in range(2) + ) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = state1.json_fragment + as_dict_1 = state1.as_dict() + as_dict_2 = state2.as_dict() + json_fragment_2 = state2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # as is the attributes and context inside regardless of + # if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_1["attributes"], ReadOnlyDict) + assert isinstance(as_dict_1["context"], ReadOnlyDict) + + assert isinstance(as_dict_2, ReadOnlyDict) + assert isinstance(as_dict_2["attributes"], ReadOnlyDict) + assert isinstance(as_dict_2["context"], ReadOnlyDict) + + def test_state_as_compressed_state() -> None: """Test a State as compressed state.""" last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) @@ -773,7 +852,7 @@ def test_state_as_compressed_state_json() -> None: last_changed=last_time, context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), ) - expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' + expected = b'"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' as_compressed_state = state.as_compressed_state_json # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers @@ -1158,6 +1237,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") @@ -1170,7 +1269,7 @@ def test_service_call_repr() -> None: ) -async def test_serviceregistry_has_service(hass: HomeAssistant) -> None: +async def test_service_registry_has_service(hass: HomeAssistant) -> None: """Test has_service method.""" hass.services.async_register("test_domain", "test_service", lambda call: None) assert len(hass.services.async_services()) == 1 @@ -1179,6 +1278,30 @@ async def test_serviceregistry_has_service(hass: HomeAssistant) -> None: assert not hass.services.has_service("non_existing", "test_service") +async def test_service_registry_service_enumeration(hass: HomeAssistant) -> None: + """Test enumerating services methods.""" + hass.services.async_register("test_domain", "test_service", lambda call: None) + services1 = hass.services.async_services() + services2 = hass.services.async_services() + assert len(services1) == 1 + assert services1 == services2 + assert services1 is not services2 # should be a copy + + services1 = hass.services.async_services_internal() + services2 = hass.services.async_services_internal() + assert len(services1) == 1 + assert services1 == services2 + assert services1 is services2 # should be the same object + + assert hass.services.async_services_for_domain("unknown") == {} + + services1 = hass.services.async_services_for_domain("test_domain") + services2 = hass.services.async_services_for_domain("test_domain") + assert len(services1) == 1 + assert services1 == services2 + assert services1 is not services2 # should be a copy + + async def test_serviceregistry_call_with_blocking_done_in_time( hass: HomeAssistant, ) -> None: @@ -1523,11 +1646,11 @@ async def test_config_as_dict() -> None: CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(), "location_name": "Home", "time_zone": "UTC", - "components": set(), + "components": [], "config_dir": "/test/ha-config", - "whitelist_external_dirs": set(), - "allowlist_external_dirs": set(), - "allowlist_external_urls": set(), + "whitelist_external_dirs": [], + "allowlist_external_dirs": [], + "allowlist_external_urls": [], "version": __version__, "config_source": ha.ConfigSource.DEFAULT, "recovery_mode": False, @@ -1624,9 +1747,7 @@ async def test_bad_timezone_raises_value_error(hass: HomeAssistant) -> None: await hass.config.async_update(time_zone="not_a_timezone") -async def test_start_taking_too_long( - event_loop, caplog: pytest.LogCaptureFixture -) -> None: +async def test_start_taking_too_long(caplog: pytest.LogCaptureFixture) -> None: """Test when async_start takes too long.""" hass = ha.HomeAssistant("/test/ha-config") caplog.set_level(logging.WARNING) @@ -1709,6 +1830,27 @@ def test_context() -> None: assert c.id is not None +def test_context_json_fragment() -> None: + """Test context JSON fragments.""" + context1, context2 = (ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW") for _ in range(2)) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = context1.json_fragment + as_dict_1 = context1.as_dict() + as_dict_2 = context2.as_dict() + json_fragment_2 = context2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # regardless of if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_2, ReadOnlyDict) + + async def test_async_functions_with_callback(hass: HomeAssistant) -> None: """Test we deal with async functions accidentally marked as callback.""" runs = [] @@ -2341,6 +2483,23 @@ async def test_state_change_events_context_id_match_state_time( ) +def test_state_timestamps() -> None: + """Test timestamp functions for State.""" + now = dt_util.utcnow() + state = ha.State( + "light.bedroom", + "on", + {"brightness": 100}, + last_changed=now, + last_updated=now, + context=ha.Context(id="1234"), + ) + assert state.last_changed_timestamp == now.timestamp() + assert state.last_changed_timestamp == now.timestamp() + assert state.last_updated_timestamp == now.timestamp() + assert state.last_updated_timestamp == now.timestamp() + + async def test_state_firing_event_matches_context_id_ulid_time( hass: HomeAssistant, ) -> None: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 602b21c15bc..d39c8faccef 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -1,4 +1,5 @@ """Test the flow classes.""" +import asyncio import dataclasses import logging from unittest.mock import Mock, patch @@ -7,7 +8,7 @@ import pytest import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.decorator import Registry from .common import ( @@ -342,6 +343,227 @@ async def test_external_step(hass: HomeAssistant, manager) -> None: async def test_show_progress(hass: HomeAssistant, manager) -> None: """Test show progress logic.""" manager.hass = hass + events = [] + task_one_evt = asyncio.Event() + task_two_evt = asyncio.Event() + event_received_evt = asyncio.Event() + + @callback + def capture_events(event: Event) -> None: + events.append(event) + event_received_evt.set() + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + start_task_two = False + task_one: asyncio.Task[None] | None = None + task_two: asyncio.Task[None] | None = None + + async def async_step_init(self, user_input=None): + async def long_running_job_one() -> None: + await task_one_evt.wait() + + async def long_running_job_two() -> None: + await task_two_evt.wait() + self.data = {"title": "Hello"} + + uncompleted_task: asyncio.Task[None] | None = None + if not self.task_one: + self.task_one = hass.async_create_task(long_running_job_one()) + + progress_action = None + if not self.task_one.done(): + progress_action = "task_one" + uncompleted_task = self.task_one + + if not uncompleted_task: + if not self.task_two: + self.task_two = hass.async_create_task(long_running_job_two()) + + if not self.task_two.done(): + progress_action = "task_two" + uncompleted_task = self.task_two + + if uncompleted_task: + assert progress_action + return self.async_show_progress( + progress_action=progress_action, + progress_task=uncompleted_task, + ) + + return self.async_show_progress_done(next_step_id="finish") + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=self.data["title"], data=self.data) + + hass.bus.async_listen( + data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED, + capture_events, + run_immediately=True, + ) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_one" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Set task one done and wait for event + task_one_evt.set() + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 1 + assert events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_two" + + # Set task two done and wait for event + task_two_evt.set() + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 2 # 1 for task one and 1 for task two + assert events[1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Hello" + + +async def test_show_progress_error(hass: HomeAssistant, manager) -> None: + """Test show progress logic.""" + manager.hass = hass + events = [] + event_received_evt = asyncio.Event() + + @callback + def capture_events(event: Event) -> None: + events.append(event) + event_received_evt.set() + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + progress_task: asyncio.Task[None] | None = None + + async def async_step_init(self, user_input=None): + async def long_running_task() -> None: + raise TypeError + + if not self.progress_task: + self.progress_task = hass.async_create_task(long_running_task()) + if self.progress_task and self.progress_task.done(): + if self.progress_task.exception(): + return self.async_show_progress_done(next_step_id="error") + return self.async_show_progress_done(next_step_id="no_error") + return self.async_show_progress( + progress_action="task", progress_task=self.progress_task + ) + + async def async_step_error(self, user_input=None): + return self.async_abort(reason="error") + + hass.bus.async_listen( + data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED, + capture_events, + run_immediately=True, + ) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Set task one done and wait for event + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 1 + assert events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "error" + + +async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) -> None: + """Test show progress done is not sent to frontend.""" + manager.hass = hass + async_show_progress_done_called = False + progress_task: asyncio.Task[None] | None = None + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + nonlocal progress_task + + async def long_running_job() -> None: + return + + if not progress_task: + progress_task = hass.async_create_task(long_running_job()) + if progress_task.done(): + nonlocal async_show_progress_done_called + async_show_progress_done_called = True + return self.async_show_progress_done(next_step_id="finish") + return self.async_show_progress( + step_id="init", + progress_action="task", + # Set to a task which never finishes to simulate flow manager has not + # yet called when frontend loads + progress_task=hass.async_create_task(asyncio.Event().wait()), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=None, data=self.data) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + await progress_task + assert not async_show_progress_done_called + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert async_show_progress_done_called + + +async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> None: + """Test show progress logic. + + This tests the deprecated version where the config flow is responsible for + resuming the flow. + """ + manager.hass = hass @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): @@ -408,8 +630,17 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: result = await manager.async_configure( result["flow_id"], {"task_finished": 2, "title": "Hello"} ) + # Note: The SHOW_PROGRESS_DONE is not hidden from frontend when flows manage + # the progress tasks themselves assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE + # Frontend refreshes the flow + result = await manager.async_configure( + result["flow_id"], {"task_finished": 2, "title": "Hello"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Hello" + await hass.async_block_till_done() assert len(events) == 2 # 1 for task one and 1 for task two assert events[1].data == { @@ -418,10 +649,12 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: "refresh": True, } - # Frontend refreshes the flow - result = await manager.async_configure(result["flow_id"]) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Hello" + # Check for deprecation warning + assert ( + "tests.test_data_entry_flow::TestFlow calls async_show_progress without passing" + " a progress task, this is not valid and will break in Home Assistant " + "Core 2024.8." + ) in caplog.text async def test_show_progress_fires_only_when_changed( @@ -546,6 +779,14 @@ async def test_async_has_matching_flow( ) -> None: """Test we can check for matching flows.""" manager.hass = hass + assert ( + manager.async_has_matching_flow( + "test", + {"source": config_entries.SOURCE_HOMEKIT}, + {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + ) + is False + ) @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): diff --git a/tests/test_loader.py b/tests/test_loader.py index 7959ddb4684..501764bd022 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -873,3 +873,14 @@ async def test_async_suggest_report_issue( ) == report_issue ) + + +async def test_config_folder_not_in_path(hass): + """Test that config folder is not in path.""" + + # Verify that we are unable to import this file from top level + with pytest.raises(ImportError): + import check_config_not_in_path # noqa: F401 + + # Verify that we are able to load the file with absolute path + import tests.testing_config.check_config_not_in_path # noqa: F401 diff --git a/tests/testing_config/check_config_not_in_path.py b/tests/testing_config/check_config_not_in_path.py new file mode 100644 index 00000000000..312adec324e --- /dev/null +++ b/tests/testing_config/check_config_not_in_path.py @@ -0,0 +1 @@ +"""File that should not be possible to import via direct import.""" diff --git a/tests/testing_config/custom_components/test/icons.json b/tests/testing_config/custom_components/test/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} 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 00000000000..57d26f28ec0 --- /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 00000000000..56404508c4c --- /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 00000000000..62624ad5db6 --- /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_embedded/icons.json b/tests/testing_config/custom_components/test_embedded/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test_embedded/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} diff --git a/tests/testing_config/custom_components/test_package/icons.json b/tests/testing_config/custom_components/test_package/icons.json new file mode 100644 index 00000000000..e82168d7a1a --- /dev/null +++ b/tests/testing_config/custom_components/test_package/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + }, + "services": { + "enable_god_mode": "mdi:shield" + } +} diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index a973135d831..3b6293d7c17 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -147,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"), [ diff --git a/tests/util/test_location.py b/tests/util/test_location.py index e998c10e565..d52362d5ee6 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -33,7 +33,7 @@ async def session(hass): @pytest.fixture -async def raising_session(event_loop): +async def raising_session(): """Return an aioclient session that only fails.""" return Mock(get=Mock(side_effect=aiohttp.ClientError)) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index e64ea01ffa8..42ba0131d71 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,6 +1,6 @@ """Test Home Assistant package util methods.""" import asyncio -from importlib.metadata import PackageNotFoundError, metadata +from importlib.metadata import metadata import logging import os from subprocess import PIPE @@ -217,7 +217,7 @@ async def test_async_get_user_site(mock_env_copy) -> None: assert ret == os.path.join(deps_dir, "lib_dir") -def test_check_package_global() -> None: +def test_check_package_global(caplog: pytest.LogCaptureFixture) -> None: """Test for an installed package.""" pkg = metadata("homeassistant") installed_package = pkg["name"] @@ -229,27 +229,32 @@ def test_check_package_global() -> None: assert package.is_installed(f"{installed_package}<={installed_version}") assert not package.is_installed(f"{installed_package}<{installed_version}") + assert package.is_installed("-1 invalid_package") is False + assert "Invalid requirement '-1 invalid_package'" in caplog.text -def test_check_package_zip() -> None: - """Test for an installed zip package.""" + +def test_check_package_fragment(caplog: pytest.LogCaptureFixture) -> None: + """Test for an installed package with a fragment.""" assert not package.is_installed(TEST_ZIP_REQ) + assert package.is_installed("git+https://github.com/pypa/pip#pip>=1") + assert not package.is_installed("git+https://github.com/pypa/pip#-1 invalid") + assert ( + "Invalid requirement 'git+https://github.com/pypa/pip#-1 invalid'" + in caplog.text + ) -def test_get_distribution_falls_back_to_version() -> None: - """Test for get_distribution failing and fallback to version.""" +def test_get_is_installed() -> None: + """Test is_installed can parse complex requirements.""" pkg = metadata("homeassistant") installed_package = pkg["name"] installed_version = pkg["version"] - with patch( - "homeassistant.util.package.distribution", - side_effect=PackageNotFoundError, - ): - assert package.is_installed(installed_package) - assert package.is_installed(f"{installed_package}=={installed_version}") - assert package.is_installed(f"{installed_package}>={installed_version}") - assert package.is_installed(f"{installed_package}<={installed_version}") - assert not package.is_installed(f"{installed_package}<{installed_version}") + assert package.is_installed(installed_package) + assert package.is_installed(f"{installed_package}=={installed_version}") + assert package.is_installed(f"{installed_package}>={installed_version}") + assert package.is_installed(f"{installed_package}<={installed_version}") + assert not package.is_installed(f"{installed_package}<{installed_version}") def test_check_package_previous_failed_install() -> None: @@ -258,9 +263,6 @@ def test_check_package_previous_failed_install() -> None: installed_package = pkg["name"] installed_version = pkg["version"] - with patch( - "homeassistant.util.package.distribution", - side_effect=PackageNotFoundError, - ), patch("homeassistant.util.package.version", return_value=None): + with patch("homeassistant.util.package.version", return_value=None): assert not package.is_installed(installed_package) assert not package.is_installed(f"{installed_package}=={installed_version}") diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index e7affecfaf4..d4649671f47 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -21,7 +21,9 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -30,6 +32,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -41,6 +44,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) INVALID_SYMBOL = "bob" @@ -54,6 +58,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { for converter in ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -65,6 +70,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) } @@ -76,6 +82,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 8, ), DistanceConverter: (UnitOfLength.KILOMETERS, UnitOfLength.METERS, 0.001), + DurationConverter: (UnitOfTime.MINUTES, UnitOfTime.SECONDS, 1 / 60), ElectricCurrentConverter: ( UnitOfElectricCurrent.AMPERE, UnitOfElectricCurrent.MILLIAMPERE, @@ -103,6 +110,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo ), UnitlessRatioConverter: (PERCENTAGE, None, 100), VolumeConverter: (UnitOfVolume.GALLONS, UnitOfVolume.LITERS, 0.264172), + VolumeFlowRateConverter: ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.06, + ), } # Dict containing a conversion test for every known unit. @@ -194,6 +206,50 @@ _CONVERTED_VALUE: dict[ (5000000, UnitOfLength.MILLIMETERS, 16404.2, UnitOfLength.FEET), (5000000, UnitOfLength.MILLIMETERS, 196850.5, UnitOfLength.INCHES), ], + DurationConverter: [ + (5, UnitOfTime.MICROSECONDS, 0.005, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MICROSECONDS, 5e-6, UnitOfTime.SECONDS), + (5, UnitOfTime.MICROSECONDS, 8.333333333333333e-8, UnitOfTime.MINUTES), + (5, UnitOfTime.MICROSECONDS, 1.388888888888889e-9, UnitOfTime.HOURS), + (5, UnitOfTime.MICROSECONDS, 5.787e-11, UnitOfTime.DAYS), + (5, UnitOfTime.MICROSECONDS, 8.267195767195767e-12, UnitOfTime.WEEKS), + (5, UnitOfTime.MILLISECONDS, 5000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MILLISECONDS, 0.005, UnitOfTime.SECONDS), + (5, UnitOfTime.MILLISECONDS, 8.333333333333333e-5, UnitOfTime.MINUTES), + (5, UnitOfTime.MILLISECONDS, 1.388888888888889e-6, UnitOfTime.HOURS), + (5, UnitOfTime.MILLISECONDS, 5.787e-8, UnitOfTime.DAYS), + (5, UnitOfTime.MILLISECONDS, 8.267195767195767e-9, UnitOfTime.WEEKS), + (5, UnitOfTime.SECONDS, 5e6, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.SECONDS, 5000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.SECONDS, 0.0833333, UnitOfTime.MINUTES), + (5, UnitOfTime.SECONDS, 0.00138889, UnitOfTime.HOURS), + (5, UnitOfTime.SECONDS, 5.787037037037037e-5, UnitOfTime.DAYS), + (5, UnitOfTime.SECONDS, 8.267195767195768e-06, UnitOfTime.WEEKS), + (5, UnitOfTime.MINUTES, 3e8, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MINUTES, 300000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MINUTES, 300, UnitOfTime.SECONDS), + (5, UnitOfTime.MINUTES, 0.0833333, UnitOfTime.HOURS), + (5, UnitOfTime.MINUTES, 0.00347222, UnitOfTime.DAYS), + (5, UnitOfTime.MINUTES, 0.000496031746031746, UnitOfTime.WEEKS), + (5, UnitOfTime.HOURS, 18000000000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.HOURS, 18000000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.HOURS, 18000, UnitOfTime.SECONDS), + (5, UnitOfTime.HOURS, 300, UnitOfTime.MINUTES), + (5, UnitOfTime.HOURS, 0.208333333, UnitOfTime.DAYS), + (5, UnitOfTime.HOURS, 0.02976190476190476, UnitOfTime.WEEKS), + (5, UnitOfTime.DAYS, 4.32e11, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.DAYS, 4.32e8, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.DAYS, 432000, UnitOfTime.SECONDS), + (5, UnitOfTime.DAYS, 7200, UnitOfTime.MINUTES), + (5, UnitOfTime.DAYS, 120, UnitOfTime.HOURS), + (5, UnitOfTime.DAYS, 0.7142857142857143, UnitOfTime.WEEKS), + (5, UnitOfTime.WEEKS, 3.024e12, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.WEEKS, 3.024e9, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.WEEKS, 3024000, UnitOfTime.SECONDS), + (5, UnitOfTime.WEEKS, 50400, UnitOfTime.MINUTES), + (5, UnitOfTime.WEEKS, 840, UnitOfTime.HOURS), + (5, UnitOfTime.WEEKS, 35, UnitOfTime.DAYS), + ], ElectricCurrentConverter: [ (5, UnitOfElectricCurrent.AMPERE, 5000, UnitOfElectricCurrent.MILLIAMPERE), (5, UnitOfElectricCurrent.MILLIAMPERE, 0.005, UnitOfElectricCurrent.AMPERE), @@ -413,6 +469,62 @@ _CONVERTED_VALUE: dict[ (5, UnitOfVolume.CENTUM_CUBIC_FEET, 3740.26, UnitOfVolume.GALLONS), (5, UnitOfVolume.CENTUM_CUBIC_FEET, 14158.42, UnitOfVolume.LITERS), ], + VolumeFlowRateConverter: [ + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 16.6666667, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 0.58857777, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 4.40286754, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.06, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.03531466, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.264172052, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 1.69901079, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 28.3168465, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 7.48051948, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ], }